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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed2be021810bf506056045c9de11c188af27c0f77ba28c9cef6c124265b13aaf
4
- data.tar.gz: a0789ce2a2707894ed251c13641f689d6d2456e067ce830f366d5bdb4c89cdbe
3
+ metadata.gz: a7881b725c1ef86850ff3a13370a7f3217bfd3a943f152d26465ff62439559ef
4
+ data.tar.gz: 55071854a05f0a10e85c749eb3a2984e04591ab6dcca7d69c5cc674675b01796
5
5
  SHA512:
6
- metadata.gz: df30932c81a94875c7aa082c6842d78593e253febc1eade648780946266cc5207ca1293a7e0b507919ead9be542600a15a6764be45fad7034504bed593a27aaf
7
- data.tar.gz: 0f9a9fc9d2a3a1eb68e6c442dcf65ff9b3f3cf692358ca1af18010e53d93a1cef8630d7652af7182cf31cd6e783910f08ed7ffe90fb028bfe1c015f864a1eaf6
6
+ metadata.gz: ab4e2654379b04db8da0e6ef63fedcf918f9eb2fc8632d3632ea1412b86cd43e68e0a3f85ca998b424d48d8da4bd1bc0099d19af0369a124d1687ed49cfa2db1
7
+ data.tar.gz: 2e26578a0581acde2d08c3ab7876a2d5868a05c91af2e1805f0609727582fdf32c2847be1571d42aa64158e0b5e5a0d4e4d5c78548e6b44a41f3513b388f453e
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.1.1] - 2026-10-12
7
+
8
+ mutaruby now scores 100% on its own code: every mutant is killed by a test or listed, with a checkable reason, in
9
+ `.mutaruby-ignore.yml`. Getting there found the bugs below.
10
+
11
+ ### Fixed
12
+
13
+ - Processes that a test run leaves behind (a server, a forked worker) are now killed when the run ends, not only on
14
+ timeout, so they cannot interfere with the next mutant.
15
+ - A project root given through a symlink (for example under `/tmp` on macOS) was copied into the sandboxes as an
16
+ empty directory, so the baseline ran without the project.
17
+ - An ignore entry for a removed sole argument (`mutation: "x =>"`) never matched: the mutant's change text had a
18
+ trailing space. Change texts are now whitespace-normalised the same way on both sides.
19
+ - Captured baseline output is decoded as UTF-8 even when Ruby's default external encoding is something else.
20
+
21
+ ### Changed
22
+
23
+ - `literal-string` also mutates `%q()` and `%Q[]` strings.
24
+ - `argument-removal` no longer skips calls on the right-hand side of a multiple assignment.
25
+ - Option parsing is available as `Mutaruby::CLI#parse`; `Runner::Options.build` supplies the defaults.
26
+ - Each library file requires what it uses and loads on its own.
27
+
6
28
  ## [0.1.0] - 2026-10-11
7
29
 
8
30
  First release.
@@ -27,4 +49,5 @@ First release.
27
49
  reason, ambiguity and staleness checks, `--strict-ignore` and `--suggest-ignore`.
28
50
  - Human, JSON and JUnit XML reports, a mutation score and `--fail-under` for CI.
29
51
 
52
+ [0.1.1]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.1
30
53
  [0.1.0]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,7 +1,15 @@
1
1
  # mutaruby
2
2
 
3
+ [![CI](https://github.com/Atlas-Authorization/mutaruby/actions/workflows/ci.yml/badge.svg)](https://github.com/Atlas-Authorization/mutaruby/actions/workflows/ci.yml)
4
+ ![mutation score on its own code: 100%](https://img.shields.io/badge/self%20mutation%20score-100%25-brightgreen)
5
+
3
6
  Free, MIT-licensed mutation testing for Ruby.
4
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
10
+ [`.mutaruby-ignore.yml`](.mutaruby-ignore.yml) with a reason you can check. `bundle exec rake mutate` reproduces the
11
+ run; it fails on any surviving mutant or stale ignore entry.
12
+
5
13
  mutaruby makes small, deliberate changes to your code, one at a time, and runs your test command against each change.
6
14
  If your tests still pass, the change (the *mutant*) **survived**, and that points to code your tests do not really check.
7
15
 
@@ -207,7 +215,7 @@ mutant's, are dropped before anything runs. `--list-operators` prints this list.
207
215
  | `loop-condition-false` | a `while` loop never runs | `while i < n` to `while false` |
208
216
  | `literal-int` | an integer becomes n+1, n-1, 0 and 1 (each distinct value once) | `retries = 3` to `4`, `2`, `0`, `1` |
209
217
  | `literal-float` | a float becomes 0.0 and x+1.0 | `0.5` to `0.0`, `1.5` |
210
- | `literal-string` | a quoted string becomes `""`; an empty one becomes `"mutaruby"` | `'admin'` to `''` |
218
+ | `literal-string` | a quoted string (`'...'`, `"..."`, `%q()`, `%Q[]`) becomes empty; an empty one becomes `"mutaruby"` | `'admin'` to `''` |
211
219
  | `literal-symbol` | a `:symbol` becomes `:__mutaruby__` | `:read` to `:__mutaruby__` |
212
220
  | `literal-boolean` | `true` and `false` | `strict = true` to `strict = false` |
213
221
  | `literal-nil` | `nil` becomes `true` | `cache = nil` to `cache = true` |
@@ -376,9 +384,10 @@ text. If it is not, the run aborts rather than testing a mutant in the wrong pla
376
384
  mutant would be "killed" and the score would mean nothing, so mutaruby stops with exit status 2 and shows the tail of
377
385
  the output. The baseline's duration also sets the default timeout.
378
386
 
379
- **Timeouts kill the whole process group.** Each run is the leader of its own process group. On timeout the group gets
380
- `SIGTERM`, then `SIGKILL` two seconds later, so a hung child (a forked server, a parallel test worker) does not
381
- outlive its mutant.
387
+ **No run outlives its mutant.** Each run is the leader of its own process group. When the run times out, and also
388
+ when it finishes normally, whatever is left of the group (a hung test, a forked server, a parallel test worker) gets
389
+ `SIGTERM`, then `SIGKILL` if it is still there two seconds later. Nothing from one mutant's run can interfere with
390
+ the next one.
382
391
 
383
392
  **Site keys instead of line numbers** for the ignore file; see [above](#equivalent-mutants-the-ignore-file).
384
393
 
data/lib/mutaruby/cli.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'optparse'
4
4
  require 'etc'
5
+ require_relative 'runner'
6
+ require_relative 'reporters'
5
7
 
6
8
  module Mutaruby
7
9
  # Command line interface. Exit status: 0 success, 1 score below --fail-under (or stale ignore entries with
@@ -10,13 +12,16 @@ module Mutaruby
10
12
  EXIT_OK = 0
11
13
  EXIT_FAIL = 1
12
14
  EXIT_ERROR = 2
15
+ EXIT_INTERRUPTED = 130
16
+
17
+ attr_reader :options, :flags
13
18
 
14
19
  def initialize(argv, stdout: $stdout, stderr: $stderr)
15
20
  @argv = argv.dup
16
21
  @stdout = stdout
17
22
  @stderr = stderr
18
- @o = Runner::Options.new(jobs: Etc.nprocessors, patterns: [], methods: [], exclude_methods: [], lines: [],
19
- exclude_lines: [], copy_excludes: [], progress: true)
23
+ @options = Runner::Options.build(jobs: Etc.nprocessors, progress: true)
24
+ @flags = {}
20
25
  @include_ops = []
21
26
  @exclude_ops = []
22
27
  end
@@ -26,78 +31,83 @@ module Mutaruby
26
31
  EXIT_OK # --help / --version
27
32
  end
28
33
 
34
+ # Parses the arguments into `options` (and `flags`); the remaining arguments are the file patterns.
35
+ def parse
36
+ parser.parse!(@argv)
37
+ @options.patterns = @argv
38
+ @options.operators = operators
39
+ self
40
+ end
41
+
29
42
  private
30
43
 
31
44
  def execute
32
- parser.parse!(@argv)
33
- @o.patterns = @argv
34
- return list_operators if @list_operators
35
-
36
- @o.operators = operators
37
- raise Error, 'missing --test COMMAND (the command that runs your tests; exit 0 = pass)' if @o.command.nil? && !@list
45
+ parse
46
+ return list_operators if @flags[:list_operators]
38
47
 
39
- runner = Runner.new(@o, out: @stderr)
40
- return list(runner.plan) if @list
48
+ runner = Runner.new(@options, out: @stderr)
49
+ return list(runner.plan) if @flags[:list]
50
+ raise Error, 'missing --test COMMAND (the command that runs your tests; exit 0 = pass)' unless @options.command
41
51
 
42
52
  report = runner.run
43
53
  emit(report)
44
54
  status(report)
45
55
  rescue OptionParser::ParseError, Error, IgnoreFile::Invalid, SpliceError => e
46
- @stderr.puts "mutaruby: #{e.message}"
56
+ @stderr.puts "mutaruby: #{e}"
47
57
  EXIT_ERROR
48
58
  rescue Interrupt
49
59
  @stderr.puts "\nmutaruby: interrupted"
50
- 130
60
+ EXIT_INTERRUPTED
51
61
  end
52
62
 
53
63
  def parser
54
- OptionParser.new do |o|
55
- o.banner = 'Usage: mutaruby --test COMMAND [options] [FILE|GLOB ...] (default: lib/**/*.rb)'
56
- o.separator ''
57
- o.separator 'Test oracle:'
58
- o.on('-t', '--test COMMAND', 'Shell command that runs the tests; exit 0 = mutant survived, non-zero = killed') { |v| @o.command = v }
59
- o.on('-C', '--root DIR', 'Project root (copied for every worker; default: current directory)') { |v| @o.root = v }
60
- o.on('-j', '--jobs N', Integer, "Parallel workers (default: #{Etc.nprocessors})") { |v| @o.jobs = v }
61
- o.on('--timeout SECONDS', Float, 'Per-mutant timeout (default: baseline time x factor + 5s)') { |v| @o.timeout = v }
62
- o.on('--timeout-factor F', Float, 'Multiplier on the baseline time for the default timeout (default: 3)') { |v| @o.timeout_factor = v }
63
- o.on('--copy-exclude GLOB', 'Do not copy matching paths into the sandboxes (repeatable; .git and coverage are always skipped)') { |v| @o.copy_excludes << v }
64
- o.separator ''
65
- o.separator 'Selection:'
66
- o.on('--since REF', 'Only files changed since a git ref (committed, staged, unstaged, untracked)') { |v| @o.since = v }
67
- o.on('--method PATTERN', 'Only mutants inside matching methods ("Calc#add", "Calc#*", "add"; repeatable)') { |v| @o.methods << v }
68
- o.on('--exclude-method PATTERN', 'Skip mutants inside matching methods (repeatable)') { |v| @o.exclude_methods << v }
69
- o.on('--line SPEC', 'Only mutants on these lines: [FILE:]LINE or [FILE:]FROM-TO (repeatable)') { |v| @o.lines << Selection.parse_line_spec(v) }
70
- o.on('--exclude-line SPEC', 'Skip mutants on these lines (repeatable)') { |v| @o.exclude_lines << Selection.parse_line_spec(v) }
71
- o.on('--operators LIST', Array, 'Only these operators (comma-separated; globs allowed, e.g. condition-*)') { |v| @include_ops.concat(v) }
72
- o.on('--exclude-operators LIST', Array, 'Skip these operators (comma-separated; globs allowed)') { |v| @exclude_ops.concat(v) }
73
- o.on('--shard I/N', 'Run only shard I of N (1-based) of the selected mutants') { |v| @o.shard = Selection.parse_shard(v) }
74
- o.separator ''
75
- o.separator 'Equivalent mutants:'
76
- o.on('--ignore-file PATH', 'Ignore file (default: .mutaruby-ignore.yml in the root, if present)') { |v| @o.ignore_file = v }
77
- o.on('--strict-ignore', 'Fail (exit 1) when an ignore entry matches no surviving mutant') { @strict = true }
78
- o.on('--suggest-ignore', 'Print ignore-file stubs for the surviving mutants (fill in each reason)') { @suggest = true }
79
- o.separator ''
80
- o.separator 'Output:'
81
- o.on('--json PATH', 'Write a JSON report ("-" = stdout)') { |v| @json = v }
82
- o.on('--junit PATH', 'Write a JUnit-style XML report ("-" = stdout)') { |v| @junit = v }
83
- o.on('--fail-under PERCENT', Float, 'Exit 1 when the mutation score is below PERCENT') { |v| @fail_under = v }
84
- o.on('--list', 'List the selected mutants without running anything') { @list = true }
85
- o.on('--list-operators', 'List the mutation operators') { @list_operators = true }
86
- o.on('-q', '--quiet', 'No progress marks') { @o.progress = false }
87
- o.on('-v', '--verbose', 'Also list timeouts and equivalent mutants') { @verbose = true }
88
- o.on('--version', 'Print the version') do
64
+ o = @options
65
+ OptionParser.new do |op|
66
+ op.banner = 'Usage: mutaruby --test COMMAND [options] [FILE|GLOB ...] (default: lib/**/*.rb)'
67
+ op.separator ''
68
+ op.separator 'Test oracle:'
69
+ op.on('-t', '--test COMMAND', 'Shell command that runs the tests; exit 0 = mutant survived, non-zero = killed') { |v| o.command = v }
70
+ op.on('-C', '--root DIR', 'Project root (copied for every worker; default: current directory)') { |v| o.root = v }
71
+ op.on('-j', '--jobs N', Integer, "Parallel workers (default: #{Etc.nprocessors})") { |v| o.jobs = v }
72
+ op.on('--timeout SECONDS', Float, 'Per-mutant timeout (default: baseline time x factor + 5s)') { |v| o.timeout = v }
73
+ op.on('--timeout-factor F', Float, 'Multiplier on the baseline time for the default timeout (default: 3)') { |v| o.timeout_factor = v }
74
+ op.on('--copy-exclude GLOB', 'Do not copy matching paths into the sandboxes (repeatable; .git and coverage are always skipped)') { |v| o.copy_excludes += [v] }
75
+ op.separator ''
76
+ op.separator 'Selection:'
77
+ op.on('--since REF', 'Only files changed since a git ref (committed, staged, unstaged, untracked)') { |v| o.since = v }
78
+ op.on('--method PATTERN', 'Only mutants inside matching methods ("Calc#add", "Calc#*", "add"; repeatable)') { |v| o.methods += [v] }
79
+ op.on('--exclude-method PATTERN', 'Skip mutants inside matching methods (repeatable)') { |v| o.exclude_methods += [v] }
80
+ op.on('--line SPEC', 'Only mutants on these lines: [FILE:]LINE or [FILE:]FROM-TO (repeatable)') { |v| o.lines += [Selection.parse_line_spec(v)] }
81
+ op.on('--exclude-line SPEC', 'Skip mutants on these lines (repeatable)') { |v| o.exclude_lines += [Selection.parse_line_spec(v)] }
82
+ op.on('--operators LIST', Array, 'Only these operators (comma-separated; globs allowed, e.g. condition-*)') { |v| @include_ops += v }
83
+ op.on('--exclude-operators LIST', Array, 'Skip these operators (comma-separated; globs allowed)') { |v| @exclude_ops += v }
84
+ op.on('--shard I/N', 'Run only shard I of N (1-based) of the selected mutants') { |v| o.shard = Selection.parse_shard(v) }
85
+ op.separator ''
86
+ op.separator 'Equivalent mutants:'
87
+ op.on('--ignore-file PATH', 'Ignore file (default: .mutaruby-ignore.yml in the root, if present)') { |v| o.ignore_file = v }
88
+ op.on('--strict-ignore', 'Fail (exit 1) when an ignore entry matches no surviving mutant') { @flags[:strict] = true }
89
+ op.on('--suggest-ignore', 'Print ignore-file stubs for the surviving mutants (fill in each reason)') { @flags[:suggest] = true }
90
+ op.separator ''
91
+ op.separator 'Output:'
92
+ op.on('--json PATH', 'Write a JSON report ("-" = stdout)') { |v| @flags[:json] = v }
93
+ op.on('--junit PATH', 'Write a JUnit-style XML report ("-" = stdout)') { |v| @flags[:junit] = v }
94
+ op.on('--fail-under PERCENT', Float, 'Exit 1 when the mutation score is below PERCENT') { |v| @flags[:fail_under] = v }
95
+ op.on('--list', 'List the selected mutants without running anything') { @flags[:list] = true }
96
+ op.on('--list-operators', 'List the mutation operators') { @flags[:list_operators] = true }
97
+ op.on('-q', '--quiet', 'No progress marks') { o.progress = false }
98
+ op.on('-v', '--verbose', 'Also list timeouts and equivalent mutants') { @flags[:verbose] = true }
99
+ op.on('--version', 'Print the version') do
89
100
  @stdout.puts "mutaruby #{VERSION}"
90
101
  throw :done
91
102
  end
92
- o.on('-h', '--help', 'Show this help') do
93
- @stdout.puts o
103
+ op.on('-h', '--help', 'Show this help') do
104
+ @stdout.puts op
94
105
  throw :done
95
106
  end
96
107
  end
97
108
  end
98
109
 
99
- private
100
-
110
+ # nil (every operator) unless --operators / --exclude-operators narrowed the set.
101
111
  def operators
102
112
  return nil if @include_ops.empty? && @exclude_ops.empty?
103
113
 
@@ -124,33 +134,31 @@ module Mutaruby
124
134
  end
125
135
 
126
136
  def emit(report)
127
- human_io = [@json, @junit].include?('-') ? @stderr : @stdout
128
- Reporters.human(report, human_io, verbose: @verbose)
129
- write(@json) { |io| Reporters.json(report, io) } if @json
130
- write(@junit) { |io| Reporters.junit(report, io) } if @junit
131
- return unless @suggest
132
-
137
+ human_io = [@flags[:json], @flags[:junit]].include?('-') ? @stderr : @stdout
138
+ Reporters.human(report, human_io, verbose: @flags[:verbose])
139
+ write(@flags[:json]) { |io| Reporters.json(report, io) } if @flags[:json]
140
+ write(@flags[:junit]) { |io| Reporters.junit(report, io) } if @flags[:junit]
133
141
  survivors = report.outcomes.select { |o| o.status == :survived }.map(&:mutation)
142
+ return unless @flags[:suggest] && survivors.any?
143
+
134
144
  @stderr.puts '# ignore-file stubs (add a reason to each; keep only true equivalents):'
135
- @stderr.puts IgnoreFile.stub_for(survivors, report.sources) if survivors.any?
145
+ @stderr.puts IgnoreFile.stub_for(survivors, report.sources)
136
146
  end
137
147
 
138
148
  def write(path, &block)
139
- if path == '-'
140
- block.call(@stdout)
141
- else
142
- File.open(path, 'w', &block)
143
- end
149
+ path == '-' ? block.call(@stdout) : File.open(path, 'w', &block)
144
150
  end
145
151
 
146
152
  def status(report)
147
153
  code = EXIT_OK
148
- if @fail_under && (report.score || 100.0) < @fail_under
149
- @stderr.puts format('mutaruby: score %s is below --fail-under %.2f%%', Reporters.score_text(report), @fail_under)
154
+ score = report.score
155
+ if @flags[:fail_under] && score && score < @flags[:fail_under]
156
+ @stderr.puts format('mutaruby: score %s is below --fail-under %.2f%%', Reporters.score_text(report), @flags[:fail_under])
150
157
  code = EXIT_FAIL
151
158
  end
152
- if @strict && report.stale.any?
153
- @stderr.puts "mutaruby: #{report.stale.size} stale ignore entr#{report.stale.size == 1 ? 'y' : 'ies'} (--strict-ignore)"
159
+ n = report.stale.size
160
+ if @flags[:strict] && n.positive?
161
+ @stderr.puts "mutaruby: #{n} stale ignore entr#{n == 1 ? 'y' : 'ies'} (--strict-ignore)"
154
162
  code = EXIT_FAIL
155
163
  end
156
164
  code
@@ -12,55 +12,61 @@ module Mutaruby
12
12
  end
13
13
 
14
14
  OUTPUT_TAIL = 4000
15
+ # After SIGTERM, a process group gets this many seconds to exit before SIGKILL.
16
+ KILL_GRACE = 2
17
+ POLL = 0.05
15
18
 
16
- def initialize(command, root:, env: {})
19
+ def initialize(command, root:, kill_grace: KILL_GRACE)
17
20
  @command = command
21
+ @kill_grace = kill_grace
18
22
  @root = File.expand_path(root)
19
- @env = env
20
- @live = {}
23
+ @live = []
21
24
  @lock = Mutex.new
22
25
  end
23
26
 
24
27
  # Kills every run still in progress (used when mutaruby itself is interrupted: the runs live in their own process
25
28
  # groups, so a Ctrl-C in the terminal does not reach them).
26
29
  def kill_all
27
- pids = @lock.synchronize { @live.keys }
28
- pids.each { |pid| kill_group(pid) }
30
+ @lock.synchronize { @live.each { |pid| kill_group(pid) } }
29
31
  end
30
32
 
31
33
  # dir: sandbox project dir. timeout: seconds (nil = none). capture: keep an output tail (for the baseline report).
32
34
  def run(dir, timeout: nil, env: {}, capture: false)
33
- log = capture ? Tempfile.new('mutaruby-out') : nil
34
- out = log ? log.path : File::NULL
35
+ return execute(dir, timeout, env, File::NULL) unless capture
36
+
37
+ Tempfile.create do |log|
38
+ result = execute(dir, timeout, env, log)
39
+ log.rewind
40
+ result.output = tail(log.read)
41
+ result
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def execute(dir, timeout, env, out)
35
48
  started = monotonic
36
- pid = Process.spawn(child_env(dir).merge(@env).merge(env), '/bin/sh', '-c', @command,
49
+ pid = Process.spawn(child_env(dir).merge(env), '/bin/sh', '-c', @command,
37
50
  chdir: dir, pgroup: true, in: File::NULL, out: out, err: out)
38
- @lock.synchronize { @live[pid] = true }
51
+ @lock.synchronize { @live << pid }
39
52
  waiter = Process.detach(pid)
40
- timed_out = waiter.join(timeout).nil? if timeout
41
- if timed_out
42
- kill_group(pid)
43
- waiter.join
44
- end
53
+ 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)
45
56
  st = waiter.value
46
57
  @lock.synchronize { @live.delete(pid) }
47
58
  status = if timed_out then :timeout
48
59
  elsif st.success? then :passed
49
60
  else :failed
50
61
  end
51
- Result.new(status: status, exitstatus: st.exitstatus, seconds: monotonic - started,
52
- output: log ? tail(log.path) : nil)
53
- ensure
54
- log&.close!
62
+ Result.new(status: status, exitstatus: st.exitstatus, seconds: monotonic - started)
55
63
  end
56
64
 
57
- private
58
-
59
65
  # Bundler exports the ORIGINAL Gemfile path to child processes. Left as is, `bundle exec` in the sandbox would
60
66
  # resolve the Gemfile (and a `gemspec` line's lib/ directory) from the user's checkout and test the unmutated code.
67
+ # The per-mutant variables are always set or cleared explicitly, so a mutaruby run nested inside another one's
68
+ # test command does not inherit the outer run's values.
61
69
  def child_env(dir)
62
- # The per-mutant variables are always set or cleared explicitly, so a mutaruby run nested inside another one's
63
- # test command does not inherit the outer run's mutant id.
64
70
  env = { 'MUTARUBY' => '1', 'MUTARUBY_MUTANT_ID' => nil, 'MUTARUBY_FILE' => nil }
65
71
  %w[BUNDLE_GEMFILE BUNDLE_APP_CONFIG].each do |k|
66
72
  v = ENV.fetch(k, nil)
@@ -72,13 +78,18 @@ module Mutaruby
72
78
  env
73
79
  end
74
80
 
81
+ # SIGTERM to the whole group, up to kill_grace seconds to exit, then SIGKILL.
75
82
  def kill_group(pid)
76
- Process.kill('TERM', -pid)
77
- deadline = monotonic + 2
78
- sleep 0.05 while monotonic < deadline && alive?(pid)
79
- Process.kill('KILL', -pid)
83
+ signal_group('TERM', pid)
84
+ 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)
80
91
  rescue Errno::ESRCH, Errno::EPERM
81
- nil
92
+ # the group is already gone
82
93
  end
83
94
 
84
95
  def alive?(pid)
@@ -88,10 +99,8 @@ module Mutaruby
88
99
  false
89
100
  end
90
101
 
91
- def tail(path)
92
- s = File.binread(path)
93
- s = s[-OUTPUT_TAIL..] if s.bytesize > OUTPUT_TAIL
94
- s.force_encoding(Encoding::UTF_8).scrub
102
+ def tail(bytes)
103
+ bytes.byteslice([bytes.bytesize - OUTPUT_TAIL, 0].max..).force_encoding(Encoding::UTF_8).scrub
95
104
  end
96
105
 
97
106
  def monotonic
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutaruby
4
+ # A user-facing error: bad options, an invalid ignore file, a failing baseline. The CLI prints the message and exits 2.
5
+ class Error < StandardError; end
6
+ end