mutineer 0.11.4 → 1.0.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,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "fileutils"
5
+
3
6
  module Mutineer
4
- # Raised when a source file's backup already exists as FileSwap.with begins.
5
- # A second mutineer run is racing on the same file (the backup path is shared
6
- # and unlocked). Aborting beats silently leaving the tree mutated.
7
+ # Raised when another process already holds exclusive ownership of a source
8
+ # file. Aborting beats silently restoring (or capturing) the other run's mutant.
7
9
  class ConcurrentRunError < StandardError
8
- def initialize(backup)
9
- super("a backup already exists at #{backup} — is another mutineer run active " \
10
- "in this directory? Aborting to avoid corrupting the source file.")
10
+ def initialize(path)
11
+ super("another mutineer run owns #{path} — aborting to avoid corrupting the source file.")
11
12
  end
12
13
  end
13
14
 
@@ -19,51 +20,92 @@ module Mutineer
19
20
  #
20
21
  # Defense in depth, mirroring the tempfile-orphan discipline
21
22
  # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
23
+ # - exclusive OS ownership (flock) is acquired before swap or recovery;
22
24
  # - the original bytes are held in memory AND written to a sibling backup;
23
25
  # - `ensure` restores from memory around every mutant;
24
26
  # - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
25
- # can self-heal a left-mutated tree on the next run's startup.
27
+ # can self-heal a left-mutated tree on the next run's startup once the
28
+ # kernel has released the dead owner's lock.
26
29
  # Only one mutant is in flight per file at a time (the external path is serial),
27
30
  # so backups never collide.
28
31
  module FileSwap
29
32
  # Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
30
33
  BACKUP_SUFFIX = ".mutineer-backup"
31
34
 
35
+ # Subdirectory beside the source that holds flock files (stable across cwd
36
+ # and `cache_dir`, so concurrent runs on one inode share one lock).
37
+ LOCK_DIR_NAME = "file-swap-locks"
38
+
39
+ # Canonical source path => open lock File held by this process.
40
+ # @api private
41
+ OWNED = {}
42
+
43
+ # Real path when the file exists, otherwise `File.expand_path`. Symlink
44
+ # aliases of one inode share this identity for locks, backups, and ownership.
45
+ #
46
+ # @param path [String] source path, relative or absolute.
47
+ # @return [String] canonical absolute path.
48
+ def self.canonical_path(path)
49
+ expanded = File.expand_path(path)
50
+ File.exist?(expanded) ? File.realpath(expanded) : expanded
51
+ end
52
+
53
+ # Holds exclusive OS ownership of each source path for the duration of the
54
+ # block. Re-entrant for paths this process already owns. Raises
55
+ # {ConcurrentRunError} when another process holds a path (non-blocking).
56
+ #
57
+ # @param paths [Array<String>] source file paths to own.
58
+ # @yield the block to run while ownership is held.
59
+ # @return [Object] the block's return value.
60
+ def self.owning(paths)
61
+ acquired = []
62
+ Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path|
63
+ next if OWNED.key?(path)
64
+
65
+ acquire!(path)
66
+ acquired << path
67
+ end
68
+ yield
69
+ ensure
70
+ acquired.reverse_each { |path| release!(path) }
71
+ end
72
+
32
73
  # Writes `mutated` to `source_file`, yields, then restores the original bytes
33
74
  # on every exit path (normal return, exception, or `ensure`). Byte-exact:
34
75
  # binary read/write preserves encoding, newlines, and trailing bytes.
76
+ # Acquires exclusive ownership first; a leftover backup with no live owner is
77
+ # treated as the original (a prior hard-killed run), not a concurrent run.
35
78
  #
36
79
  # @param source_file [String] path to the real source file.
37
80
  # @param mutated [String] mutated source text to write for the duration.
38
81
  # @yield the block to run while the mutant is on disk.
39
82
  # @return [Object] the block's return value.
40
83
  def self.with(source_file, mutated)
41
- backup = source_file + BACKUP_SUFFIX
42
- # A backup already on disk means either a prior hard-killed run
43
- # (restore_orphans should have healed it at startup) or a SECOND mutineer
44
- # run racing us on the same file. The backup path is shared and unlocked, so
45
- # proceeding would let us capture the other run's mutant AS the "original"
46
- # and permanently mutate the tree. Refuse loudly rather than silently
47
- # corrupt, and do it BEFORE `created` is set, so the ensure below never
48
- # touches a backup we don't own.
49
- raise ConcurrentRunError, backup if File.exist?(backup)
50
-
51
- original = File.binread(source_file)
52
- File.binwrite(backup, original)
53
- created = true
54
- File.binwrite(source_file, mutated)
55
- yield
56
- ensure
57
- if created
58
- File.binwrite(source_file, original)
59
- File.unlink(backup) if File.exist?(backup)
84
+ path = canonical_path(source_file)
85
+ created = false
86
+ original = nil
87
+ backup = path + BACKUP_SUFFIX
88
+ owning([path]) do
89
+ begin
90
+ original = File.exist?(backup) ? File.binread(backup) : File.binread(path)
91
+ File.binwrite(backup, original)
92
+ created = true
93
+ File.binwrite(path, mutated)
94
+ yield
95
+ ensure
96
+ if created
97
+ File.binwrite(path, original)
98
+ File.unlink(backup) if File.exist?(backup)
99
+ end
100
+ end
60
101
  end
61
102
  end
62
103
 
63
104
  # Startup/after-run self-heal: restore any source file left mutated by a prior
64
105
  # interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
65
- # Prints one line to stderr when it actually heals something, so a developer
66
- # knows their working tree was auto-restored (a file they did not touch).
106
+ # Skips a backup whose source is owned by a live process. Prints one line to
107
+ # stderr when it actually heals something, so a developer knows their working
108
+ # tree was auto-restored (a file they did not touch).
67
109
  #
68
110
  # @param dirs [Array<String>] directories to sweep for orphaned backups.
69
111
  # @return [void]
@@ -72,19 +114,10 @@ module Mutineer
72
114
  dirs.uniq.each do |dir|
73
115
  Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
74
116
  source_file = backup.delete_suffix(BACKUP_SUFFIX)
75
- backup_bytes = File.binread(backup)
76
- if !File.exist?(source_file)
77
- # A real user file that merely ends in our suffix, with no sibling to
78
- # restore. Leave it untouched (never create a file from it).
117
+ begin
118
+ owning([source_file]) { healed += restore_one(backup, source_file) }
119
+ rescue ConcurrentRunError
79
120
  next
80
- elsif File.binread(source_file) == backup_bytes
81
- # Redundant backup (e.g. a crash between restore and unlink): nothing
82
- # to heal, just clear the orphan so the next run does not see a false race.
83
- File.unlink(backup)
84
- else
85
- File.binwrite(source_file, backup_bytes)
86
- File.unlink(backup)
87
- healed += 1
88
121
  end
89
122
  end
90
123
  end
@@ -92,5 +125,68 @@ module Mutineer
92
125
 
93
126
  warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
94
127
  end
128
+
129
+ # Exclusive non-blocking flock for canonical `path`.
130
+ #
131
+ # @api private
132
+ # @param path [String] canonical source path.
133
+ # @return [void]
134
+ # @raise [Mutineer::ConcurrentRunError] when the lock is held elsewhere.
135
+ def self.acquire!(path)
136
+ file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644)
137
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
138
+ file.close
139
+ raise ConcurrentRunError, path
140
+ end
141
+ OWNED[path] = file
142
+ end
143
+
144
+ # Flock path for a canonical source: `<dir>/.mutineer/file-swap-locks/<sha>`.
145
+ # Derived only from the source path so cwd and `cache_dir` cannot split a lock.
146
+ #
147
+ # @api private
148
+ # @param path [String] canonical source path.
149
+ # @return [String] lock file path.
150
+ def self.lock_file(path)
151
+ dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME)
152
+ FileUtils.mkdir_p(dir)
153
+ File.join(dir, Digest::SHA256.hexdigest(path))
154
+ end
155
+
156
+ # Releases a lock acquired by {acquire!}.
157
+ #
158
+ # @api private
159
+ # @param path [String] expanded source path.
160
+ # @return [void]
161
+ def self.release!(path)
162
+ file = OWNED.delete(path)
163
+ return unless file
164
+
165
+ file.flock(File::LOCK_UN)
166
+ file.close
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ # Restores one backup if the sibling source exists. Returns 1 when bytes
172
+ # were written back, 0 when the backup was redundant or had no sibling.
173
+ #
174
+ # @api private
175
+ # @param backup [String] path to the `*.mutineer-backup` file.
176
+ # @param source_file [String] corresponding source path.
177
+ # @return [Integer] 1 if healed, otherwise 0.
178
+ def self.restore_one(backup, source_file)
179
+ return 0 unless File.exist?(source_file)
180
+
181
+ backup_bytes = File.binread(backup)
182
+ if File.binread(source_file) == backup_bytes
183
+ File.unlink(backup)
184
+ 0
185
+ else
186
+ File.binwrite(source_file, backup_bytes)
187
+ File.unlink(backup)
188
+ 1
189
+ end
190
+ end
95
191
  end
96
192
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Coarse run progress on stderr: one line per 10% step of completed mutants
5
+ # (which means every completion on a run smaller than ten). Shared by every
6
+ # backend so a long run is never silent between config resolution and the
7
+ # report. Thread-safe so the daemon's parallel workers can share one instance.
8
+ # Writes only to stderr, keeping `--format json` stdout and `--output` files
9
+ # byte-exact per the Reporter's stream contract.
10
+ class Progress
11
+ # Builds a progress counter for a run of `total` mutants.
12
+ #
13
+ # @param total [Integer] number of mutants that will run.
14
+ # @param io [IO] destination stream (defaults to stderr; injectable for tests).
15
+ def initialize(total, io: $stderr)
16
+ @total = total
17
+ @done = 0
18
+ @last_step = 0
19
+ @mutex = Mutex.new
20
+ @io = io
21
+ end
22
+
23
+ # Records one completed mutant and prints when a 10% boundary is crossed.
24
+ # A zero-total counter never prints (guards the division, and an empty run
25
+ # has nothing to report).
26
+ #
27
+ # @return [void]
28
+ def tick
29
+ @mutex.synchronize do
30
+ return if @total.zero?
31
+
32
+ @done += 1
33
+ step = (@done * 10) / @total
34
+ return if step <= @last_step
35
+
36
+ @last_step = step
37
+ @io.puts "[mutineer] #{@done}/#{@total} mutants (#{(@done * 100) / @total}%)"
38
+ end
39
+ rescue IOError, Errno::EPIPE
40
+ # Progress is best-effort: a closed stderr (piped consumer went away) must
41
+ # never kill the run — in the daemon's worker threads a raise here would
42
+ # propagate through Thread#join and abort scoring.
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -32,11 +32,14 @@ module Mutineer
32
32
 
33
33
  # Single entry point. Branches on `format` ("human" | "json" | "html") and
34
34
  # routes the rendered report to `output` (a file, with a stderr confirmation)
35
- # or to `out`. Diagnostics always go to `err`.
36
- def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil, baseline: nil)
35
+ # or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
36
+ # (`--since`) run; the JSON report records it so a consumer (or a later
37
+ # `--baseline` load) knows the score covers only the changed-line mutants.
38
+ def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
39
+ baseline: nil, scoped: false)
37
40
  rendered =
38
41
  if format == "json"
39
- json_report(baseline)
42
+ json_report(baseline, scoped: scoped)
40
43
  elsif format == "html"
41
44
  html_report
42
45
  else
@@ -126,8 +129,10 @@ module Mutineer
126
129
  #
127
130
  # @api private
128
131
  # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
132
+ # @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
133
+ # covers only the changed-line mutants (additive `summary.scoped` key).
129
134
  # @return [String] JSON text.
130
- def json_report(baseline = nil)
135
+ def json_report(baseline = nil, scoped: false)
131
136
  killed = @agg.killed_count
132
137
  survived = @agg.survived_count
133
138
  # null (not 0.0) on an empty denominator, matching the nil-vs-0.0
@@ -136,7 +141,7 @@ module Mutineer
136
141
  score = @agg.mutation_score
137
142
 
138
143
  doc = {
139
- schema_version: "1.2",
144
+ schema_version: "1.3",
140
145
  summary: {
141
146
  total: @agg.total, killed: killed, survived: survived,
142
147
  no_coverage: @agg.no_coverage_count,
@@ -146,7 +151,11 @@ module Mutineer
146
151
  ignored: @agg.ignored_count,
147
152
  # The gate is computed from these two, so a consumer never re-derives them.
148
153
  attempted: attempted_count, no_verdict: no_verdict_count,
149
- score: score
154
+ score: score,
155
+ # Additive: true when the run was diff-scoped (--since). The score then
156
+ # covers only the changed-line mutants, so it is not comparable to a
157
+ # full-run score; Baseline#diff reads this to skip the score-drop gate.
158
+ scoped: scoped
150
159
  },
151
160
  survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
152
161
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
@@ -306,6 +315,10 @@ module Mutineer
306
315
  score_before: delta.score_before,
307
316
  score_after: delta.score_after,
308
317
  score_dropped: delta.score_drop,
318
+ # Additive: false when the score-drop check was skipped (a nil score or
319
+ # a diff-scoped side), so a consumer knows not to render the two scores
320
+ # as a comparison.
321
+ score_comparable: delta.score_comparable,
309
322
  new_survivors: delta.new_survivors.map { |r| ignored_json(r) }
310
323
  .sort_by { |h| [h[:file], h[:line], h[:operator]] },
311
324
  fixed_survivors: delta.fixed_survivors.map do |h|
@@ -577,6 +590,9 @@ module Mutineer
577
590
  out.puts " + #{r.subject.qualified_name} (#{file}:#{line}) #{r.mutation.operator}"
578
591
  end
579
592
  out.puts "score dropped #{delta.score_before}% -> #{delta.score_after}%" if delta.score_drop
593
+ # An OK verdict must not imply a check that never ran: say when the score
594
+ # comparison was skipped (a diff-scoped side or a nil score).
595
+ out.puts "score-drop check skipped (scores not comparable)" unless delta.score_comparable
580
596
  out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
581
597
  end
582
598
 
@@ -10,6 +10,7 @@ require_relative "coverage_map"
10
10
  require_relative "changed_lines"
11
11
  require_relative "mutator_registry"
12
12
  require_relative "worker_pool"
13
+ require_relative "progress"
13
14
  require_relative "mutant_id"
14
15
  require_relative "file_swap"
15
16
  require_relative "external_backend"
@@ -98,6 +99,7 @@ module Mutineer
98
99
  load_paths: config.load_paths, framework: config.framework
99
100
  ).build_or_load
100
101
  end
102
+ abort_if_unclean!(coverage_map)
101
103
 
102
104
  # Collect every (subject, mutation) up front so the pool can fan them out.
103
105
  jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
@@ -121,7 +123,9 @@ module Mutineer
121
123
  begin
122
124
  framework = config.framework
123
125
  stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
124
- bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when) do |subject, mutation|
126
+ progress = Progress.new(jobs.size)
127
+ bare = WorkerPool.new(jobs_n).run(jobs, stop_when: stop_when,
128
+ on_result: ->(_r) { progress.tick }) do |subject, mutation|
125
129
  run(mutation, source_file: subject.file, coverage_map: coverage_map,
126
130
  subject: subject, strategy: strategy, rails: config.rails, framework: framework)
127
131
  end
@@ -181,42 +185,50 @@ module Mutineer
181
185
  # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
182
186
  def self.execute_external(config, operator_classes)
183
187
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
184
- dirs = source_dirs(config)
188
+ sources = config.sources.map { |s| FileSwap.canonical_path(File.expand_path(s, config.project_root)) }
189
+ dirs = sources.map { |s| File.dirname(s) }.uniq
190
+
191
+ # Own every source before healing leftovers or reading bytes for mutation.
192
+ # A backup file alone is not ownership; flock is. Canonical paths so
193
+ # symlink aliases of one inode share one lock, independent of cache_dir.
194
+ FileSwap.owning(sources) do
195
+ # Heal any file a prior hard-killed run left mutated BEFORE reading source.
196
+ # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
197
+ # still-mutated file would yield garbage offsets against the later-healed
198
+ # source. Heal first, then discover jobs from the clean tree.
199
+ FileSwap.restore_orphans(dirs)
185
200
 
186
- # Heal any file a prior hard-killed run left mutated BEFORE reading source.
187
- # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
188
- # still-mutated file would yield garbage offsets against the later-healed
189
- # source. Heal first, then discover jobs from the clean tree.
190
- FileSwap.restore_orphans(dirs)
201
+ jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
202
+ jobs = filter_since(jobs, source_map, config) if config.since
191
203
 
192
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
193
- jobs = filter_since(jobs, source_map, config) if config.since
204
+ # Nothing to mutate: return before the smoke check, which runs the whole
205
+ # --test set to calibrate a timeout no mutant would use (#76).
206
+ next [AggregateResult.new(ignored_results), source_map] if jobs.empty?
207
+
208
+ # Calibrate the per-mutant timeout from the clean run (a real suite far
209
+ # outlasts the 10s in-process fork budget), and abort if it is not green.
210
+ # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
211
+ # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
212
+ # handful cannot stall a serial run for ~45min on a slow suite.
213
+ smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
214
+ timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
194
215
 
195
- # Nothing to mutate: return before the smoke check, which runs the whole
196
- # --test set to calibrate a timeout no mutant would use (#76).
197
- return [AggregateResult.new(ignored_results), source_map] if jobs.empty?
198
-
199
- # Calibrate the per-mutant timeout from the clean run (a real suite far
200
- # outlasts the 10s in-process fork budget), and abort if it is not green.
201
- # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
202
- # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
203
- # handful cannot stall a serial run for ~45min on a slow suite.
204
- smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
205
- timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
206
-
207
- results = []
208
- begin
209
- jobs.each do |subject, mutation, id|
210
- r = run_external(subject, mutation, config.test_command, abs_tests,
211
- timeout: timeout, verbose: config.verbose)
212
- results << r.with(subject: subject, mutation: mutation, id: id)
213
- break if config.fail_fast && r.survived? # stop at the first survivor
216
+ results = []
217
+ progress = Progress.new(jobs.size)
218
+ begin
219
+ jobs.each do |subject, mutation, id|
220
+ r = run_external(subject, mutation, config.test_command, abs_tests,
221
+ timeout: timeout, verbose: config.verbose)
222
+ results << r.with(subject: subject, mutation: mutation, id: id)
223
+ progress.tick
224
+ break if config.fail_fast && r.survived? # stop at the first survivor
225
+ end
226
+ ensure
227
+ FileSwap.restore_orphans(dirs)
214
228
  end
215
- ensure
216
- FileSwap.restore_orphans(dirs)
217
- end
218
229
 
219
- [AggregateResult.new(results + ignored_results), source_map]
230
+ [AggregateResult.new(results + ignored_results), source_map]
231
+ end
220
232
  end
221
233
 
222
234
  # Runs one mutant through the external backend: apply the whole-file mutation
@@ -236,6 +248,21 @@ module Mutineer
236
248
  end
237
249
  end
238
250
 
251
+ # Aborts the run when coverage capture saw a red unmutated suite. Scoring
252
+ # those results would treat existing assertion failures as killed mutants.
253
+ #
254
+ # @param coverage_map [Mutineer::CoverageMap] the built or loaded map.
255
+ # @return [void]
256
+ # @raise [Mutineer::SmokeCheckError] when any captured test failed clean.
257
+ def self.abort_if_unclean!(coverage_map)
258
+ files = coverage_map.failed_clean_tests
259
+ return if files.empty?
260
+
261
+ raise SmokeCheckError,
262
+ "the unmutated suite is not green (#{files.join(', ')}) — " \
263
+ "#{ExternalBackend.generic_env_hint}."
264
+ end
265
+
239
266
  # Coverage-based test selection, shared by the in-process ({run}) and daemon
240
267
  # paths so both narrow identically (score parity). Returns
241
268
  # `[:run, abs_test_paths]` when some test covers the mutant's line, or
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.11.4"
5
+ VERSION = "1.0.1"
6
6
  end
@@ -28,10 +28,15 @@ module Mutineer
28
28
  # @param stop_when [Proc, nil] called with each collected Result; when it
29
29
  # returns truthy, no further items are scheduled and the run drains and
30
30
  # returns early (--fail-fast). Unscheduled slots stay nil.
31
+ # @param on_result [Proc, nil] called in the parent with each collected
32
+ # Result as it is reaped, in finish order (progress reporting); its
33
+ # return value is ignored. Must be fast and non-blocking: it runs on the
34
+ # pool's single reap thread, so a slow callback stalls draining the other
35
+ # in-flight children's pipes (the #4 deadlock discipline).
31
36
  # @yieldparam item [Array] one work item.
32
37
  # @return [Array<Mutineer::Result>] results in input order (nil for any item
33
38
  # left unscheduled by an early stop).
34
- def run(items, stop_when: nil)
39
+ def run(items, stop_when: nil, on_result: nil)
35
40
  results = Array.new(items.size)
36
41
  queue = (0...items.size).to_a
37
42
  running = {} # pid => [index, read_io, buffer]
@@ -40,6 +45,7 @@ module Mutineer
40
45
  until queue.empty? && running.empty?
41
46
  fill(items, queue, running) { |*args| yield(*args) } unless stopping
42
47
  result = reap(results, running)
48
+ on_result&.call(result) if result
43
49
  if !stopping && stop_when && result && stop_when.call(result)
44
50
  stopping = true
45
51
  queue.clear # schedule no more; let in-flight workers drain
data/lib/mutineer.rb CHANGED
@@ -27,6 +27,7 @@ require_relative "mutineer/mutators/regex_literal"
27
27
  require_relative "mutineer/mutators/collection_method"
28
28
  require_relative "mutineer/mutator_registry"
29
29
  require_relative "mutineer/worker_pool"
30
+ require_relative "mutineer/progress"
30
31
  require_relative "mutineer/runner"
31
32
  require_relative "mutineer/reporter"
32
33
  require_relative "mutineer/baseline"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.4
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -95,6 +95,7 @@ files:
95
95
  - lib/mutineer/mutators/string_literal.rb
96
96
  - lib/mutineer/pairing.rb
97
97
  - lib/mutineer/parser.rb
98
+ - lib/mutineer/progress.rb
98
99
  - lib/mutineer/project.rb
99
100
  - lib/mutineer/rails_worker_db.rb
100
101
  - lib/mutineer/reporter.rb
@@ -113,6 +114,7 @@ metadata:
113
114
  source_code_uri: https://github.com/davidteren/mutineer
114
115
  changelog_uri: https://github.com/davidteren/mutineer/blob/main/CHANGELOG.md
115
116
  bug_tracker_uri: https://github.com/davidteren/mutineer/issues
117
+ documentation_uri: https://davidteren.github.io/mutineer/
116
118
  rubygems_mfa_required: 'true'
117
119
  rdoc_options: []
118
120
  require_paths: