mutineer 0.7.0 → 0.9.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +39 -4
  4. data/lib/mutineer/baseline.rb +29 -13
  5. data/lib/mutineer/changed_lines.rb +29 -12
  6. data/lib/mutineer/cli.rb +88 -20
  7. data/lib/mutineer/config.rb +24 -2
  8. data/lib/mutineer/coverage_map.rb +86 -4
  9. data/lib/mutineer/isolation.rb +89 -40
  10. data/lib/mutineer/minitest_integration.rb +13 -9
  11. data/lib/mutineer/mutant_id.rb +24 -5
  12. data/lib/mutineer/mutation.rb +12 -6
  13. data/lib/mutineer/mutator_registry.rb +33 -6
  14. data/lib/mutineer/mutators/arithmetic.rb +8 -2
  15. data/lib/mutineer/mutators/base.rb +11 -3
  16. data/lib/mutineer/mutators/boolean_connector.rb +15 -5
  17. data/lib/mutineer/mutators/boolean_literal.rb +19 -6
  18. data/lib/mutineer/mutators/collection_method.rb +44 -0
  19. data/lib/mutineer/mutators/comparison.rb +7 -8
  20. data/lib/mutineer/mutators/condition_negation.rb +14 -5
  21. data/lib/mutineer/mutators/literal_mutation.rb +15 -4
  22. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  23. data/lib/mutineer/mutators/return_nil.rb +22 -7
  24. data/lib/mutineer/mutators/statement_removal.rb +6 -9
  25. data/lib/mutineer/mutators/string_literal.rb +34 -0
  26. data/lib/mutineer/pairing.rb +32 -13
  27. data/lib/mutineer/parser.rb +17 -7
  28. data/lib/mutineer/project.rb +73 -2
  29. data/lib/mutineer/reporter.rb +169 -0
  30. data/lib/mutineer/result.rb +100 -32
  31. data/lib/mutineer/runner.rb +29 -2
  32. data/lib/mutineer/subject.rb +10 -6
  33. data/lib/mutineer/test_runners/minitest.rb +5 -4
  34. data/lib/mutineer/test_runners/rspec.rb +10 -17
  35. data/lib/mutineer/test_runners.rb +9 -2
  36. data/lib/mutineer/version.rb +2 -1
  37. data/lib/mutineer/worker_pool.rb +51 -29
  38. data/lib/mutineer.rb +4 -0
  39. metadata +18 -1
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # One discoverable method: its location, namespace context, and the live
5
- # Prism::DefNode mutators walk. Struct (not Data) because def_node is a live
6
- # AST node value-equality would be hollow, so we don't promise it.
4
+ # One discoverable method and its AST node.
5
+ #
6
+ # Location, namespace context, and the live Prism::DefNode are kept together
7
+ # because mutators walk the def node directly.
7
8
  Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, keyword_init: true) do
8
- # e.g. "Billing::Invoice#total", "Billing::Invoice.build".
9
- # Top-level (empty namespace) -> "#name" (no :: prefix).
9
+ # Returns the fully-qualified subject name.
10
+ #
11
+ # @return [String] namespaced method name like `Billing::Invoice#total`.
10
12
  def qualified_name
11
13
  namespace.join("::") + (singleton ? "." : "#") + name.to_s
12
14
  end
13
15
 
14
- # nil for empty methods (def empty; end).
16
+ # Returns the body location for the subject, if any.
17
+ #
18
+ # @return [Prism::Location, nil] body location or nil for empty methods.
15
19
  def body_loc
16
20
  def_node.body&.location
17
21
  end
@@ -4,11 +4,12 @@ require_relative "../minitest_integration"
4
4
 
5
5
  module Mutineer
6
6
  module TestRunners
7
- # Uniform wrapper over the existing MinitestIntegration impl so the runner
8
- # selection (TestRunners.for) has one method shape across frameworks.
9
- # MinitestIntegration stays the implementation — all its behaviour (autorun
10
- # neutralisation, Runnable.reset, load, Minitest.run -> 0/1) is preserved.
7
+ # Thin wrapper around the shared Minitest integration runner.
11
8
  module Minitest
9
+ # Runs the given Minitest files.
10
+ #
11
+ # @param test_files [String, Array<String>] one file or many files.
12
+ # @return [Integer] 0 on success, 1 on failure.
12
13
  def self.run(test_files) = MinitestIntegration.run(test_files)
13
14
  end
14
15
  end
@@ -8,31 +8,21 @@ module Mutineer
8
8
  class FrameworkUnavailable < StandardError; end
9
9
 
10
10
  module TestRunners
11
- # Child-process-only RSpec runner, mirroring MinitestIntegration's contract:
12
- # run the given spec files and return 0 (all passed) / 1 (any failure).
11
+ # Child-process-only RSpec runner.
13
12
  #
14
- # rspec-core is required LAZILY here, never at load time, so Mutineer keeps
15
- # zero runtime gem deps; a missing rspec raises a clear Mutineer error.
16
- #
17
- # No `rescue` around the run itself: Isolation.run's fork block is the single
18
- # exception boundary (any exception there -> exit 2), keeping the 0/1 contract.
13
+ # Mirrors MinitestIntegration's contract: run the given spec files and
14
+ # return 0 (all passed) or 1 (any failure).
19
15
  module RSpec
20
- # `spec_files` is one path or an Array of paths (coverage selection passes
21
- # the covering subset). All are loaded+run in a single RSpec invocation.
16
+ # Runs the given RSpec files.
17
+ #
18
+ # @param spec_files [String, Array<String>] one file or many files.
19
+ # @return [Integer] 0 on success, 1 on failure.
22
20
  def self.run(spec_files)
23
21
  require_rspec!
24
22
 
25
- # rspec/autorun (if a spec_helper required it) installs an at_exit run;
26
- # neutralise it like Minitest.autorun so it never double-fires.
27
23
  ::RSpec::Core::Runner.disable_autorun!
28
-
29
- # Drop any RSpec world/configuration inherited across runs in one process
30
- # (e.g. successive forks of a booted parent) so examples never accumulate.
31
24
  ::RSpec.reset
32
25
 
33
- # Silence RSpec's formatter output (and any stray puts/deprecations) so it
34
- # never pollutes Mutineer's report streams. The formatter writes to the
35
- # passed IO; $stdout/$stderr are redirected to catch everything else.
36
26
  sink = StringIO.new
37
27
  orig_out = $stdout
38
28
  orig_err = $stderr
@@ -48,6 +38,9 @@ module Mutineer
48
38
  status.zero? ? 0 : 1
49
39
  end
50
40
 
41
+ # Requires rspec-core from the project under test.
42
+ #
43
+ # @api private
51
44
  def self.require_rspec!
52
45
  require "rspec/core"
53
46
  rescue LoadError
@@ -4,9 +4,16 @@ require_relative "test_runners/minitest"
4
4
  require_relative "test_runners/rspec"
5
5
 
6
6
  module Mutineer
7
- # Picks the test-framework runner. Each runner responds to `.run(files) -> 0/1`
8
- # (0 = all passed, 1 = any failure) and is called only inside a forked child.
7
+ # Picks the test-framework runner.
8
+ #
9
+ # Each runner responds to `.run(files) -> 0/1` (0 = all passed, 1 = any
10
+ # failure) and is called only inside a forked child.
9
11
  module TestRunners
12
+ # Returns the runner module for a framework name.
13
+ #
14
+ # @param framework [String, nil] framework name or nil.
15
+ # @return [Module] `Mutineer::TestRunners::Minitest` or `::RSpec`.
16
+ # @raise [Mutineer::ConfigError] when the framework is unknown.
10
17
  def self.for(framework)
11
18
  case framework
12
19
  when "rspec" then RSpec
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- VERSION = "0.7.0"
4
+ # Current Mutineer release version.
5
+ VERSION = "0.9.0"
5
6
  end
@@ -3,29 +3,47 @@
3
3
  require_relative "result"
4
4
 
5
5
  module Mutineer
6
- # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at once;
7
- # each child runs the block on one work item, marshals its Result to a private
8
- # pipe, and exits. The parent reaps any finished child with Process.wait2(-1),
9
- # opening exactly one slot per reap, then refills. Results are returned in the
10
- # SAME ORDER as `items` regardless of finish order, so verdicts are identical to
11
- # a serial run (R4) and downstream output is stable.
6
+ # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at
7
+ # once; each child runs the block on one work item, marshals its Result to a
8
+ # private pipe, and exits. The parent reaps any finished child with
9
+ # Process.wait2(-1), opening exactly one slot per reap, then refills.
10
+ # Results are returned in the SAME ORDER as `items` regardless of finish
11
+ # order, so verdicts are identical to a serial run (R4) and downstream output
12
+ # is stable.
12
13
  #
13
14
  # The block is run inside the child via `yield(*items[i])`; whatever it
14
- # returns (a Result) is the marshaled payload. Per-mutant timeout is handled one
15
- # level down by Isolation (KTD2) — the pool adds no separate wall clock.
15
+ # returns (a Result) is the marshaled payload. Per-mutant timeout is handled
16
+ # one level down by Isolation (KTD2) — the pool adds no separate wall clock.
16
17
  class WorkerPool
18
+ # Builds a pool.
19
+ #
20
+ # @param size [Integer] desired pool size.
17
21
  def initialize(size)
18
22
  @size = [size.to_i, 1].max
19
23
  end
20
24
 
21
- def run(items)
22
- results = Array.new(items.size)
23
- queue = (0...items.size).to_a
24
- running = {} # pid => [index, read_io]
25
+ # Runs the work items through the pool.
26
+ #
27
+ # @param items [Array<Array>] work items.
28
+ # @param stop_when [Proc, nil] called with each collected Result; when it
29
+ # returns truthy, no further items are scheduled and the run drains and
30
+ # returns early (#21 --fail-fast). Unscheduled slots stay nil.
31
+ # @yieldparam item [Array] one work item.
32
+ # @return [Array<Mutineer::Result>] results in input order (nil for any item
33
+ # left unscheduled by an early stop).
34
+ def run(items, stop_when: nil)
35
+ results = Array.new(items.size)
36
+ queue = (0...items.size).to_a
37
+ running = {} # pid => [index, read_io, buffer]
38
+ stopping = false
25
39
 
26
40
  until queue.empty? && running.empty?
27
- fill(items, queue, running) { |*args| yield(*args) }
28
- reap(results, running)
41
+ fill(items, queue, running) { |*args| yield(*args) } unless stopping
42
+ result = reap(results, running)
43
+ if !stopping && stop_when && result && stop_when.call(result)
44
+ stopping = true
45
+ queue.clear # schedule no more; let in-flight workers drain
46
+ end
29
47
  end
30
48
 
31
49
  results
@@ -33,17 +51,19 @@ module Mutineer
33
51
 
34
52
  private
35
53
 
54
+ # R1: the child must ALWAYS hard-exit. If yield raises, marshal an error
55
+ # Result and exit! in `ensure` — otherwise the child unwinds normally and
56
+ # our Minitest at_exit autorun re-runs the parent suite inside the worker,
57
+ # losing the real error.
36
58
  def fill(items, queue, running)
37
59
  while running.size < @size && !queue.empty?
38
60
  idx = queue.shift
39
61
  rd, wr = IO.pipe
62
+ rd.binmode # #19: Marshal output is binary — keep the pipe byte-exact
63
+ wr.binmode
40
64
  begin
41
65
  pid = fork do
42
66
  rd.close
43
- # R1: the child must ALWAYS hard-exit. If yield raises, marshal an
44
- # error Result and exit! in `ensure` — otherwise the child unwinds
45
- # normally and our Minitest at_exit autorun re-runs the parent suite
46
- # inside the worker, losing the real error.
47
67
  payload =
48
68
  begin
49
69
  yield(*items[idx])
@@ -74,13 +94,13 @@ module Mutineer
74
94
  end
75
95
  end
76
96
 
77
- # Drain pipes with IO.select and reap a child only on EOF (#4). The old code
78
- # reaped first and read after — but a child whose payload exceeds the OS pipe
79
- # buffer (~64KB) blocks on `write` before it can exit, so it was never reaped
80
- # and the pool deadlocked. Reading concurrently keeps the pipe drained so the
81
- # child can finish and exit; EOF means it closed its write end (done writing).
82
- # We waitpid only OUR known pids (R6: never wait2(-1), which would steal the
83
- # host suite's children).
97
+ # Drain pipes with IO.select and reap a child only on EOF (#4). The old
98
+ # code reaped first and read after — but a child whose payload exceeds the
99
+ # OS pipe buffer (~64KB) blocks on `write` before it can exit, so it was
100
+ # never reaped and the pool deadlocked. Reading concurrently keeps the
101
+ # pipe drained so the child can finish and exit; EOF means it closed its
102
+ # write end (done writing). We waitpid only OUR known pids (R6: never
103
+ # wait2(-1), which would steal the host suite's children).
84
104
  def reap(results, running)
85
105
  return if running.empty?
86
106
 
@@ -96,19 +116,21 @@ module Mutineer
96
116
  rd.close
97
117
  Process.waitpid(pid) # reap the now-finished child (no zombie)
98
118
  running.delete(pid)
99
- results[idx] = decode(buf)
100
- return
119
+ # Return the collected Result so the caller's stop_when (#21) can see it.
120
+ return results[idx] = decode(buf)
101
121
  end
102
122
  buf << chunk
103
123
  end
104
124
  end
105
125
  end
106
126
 
127
+ # R6: a partial/garbage Marshal stream (dead worker) must not crash the
128
+ # pool — degrade to an error Result.
129
+ # @param data [String] marshaled payload.
130
+ # @return [Mutineer::Result] decoded result or error result.
107
131
  def decode(data)
108
132
  return Result.error("worker produced no result") if data.empty?
109
133
 
110
- # R6: a partial/garbage Marshal stream (dead worker) must not crash the
111
- # pool — degrade to an error Result.
112
134
  Marshal.load(data)
113
135
  rescue StandardError => e
114
136
  Result.error("worker result unreadable: #{e.class}: #{e.message}")
data/lib/mutineer.rb CHANGED
@@ -22,6 +22,9 @@ require_relative "mutineer/mutators/statement_removal"
22
22
  require_relative "mutineer/mutators/return_nil"
23
23
  require_relative "mutineer/mutators/literal_mutation"
24
24
  require_relative "mutineer/mutators/condition_negation"
25
+ require_relative "mutineer/mutators/string_literal"
26
+ require_relative "mutineer/mutators/regex_literal"
27
+ require_relative "mutineer/mutators/collection_method"
25
28
  require_relative "mutineer/mutator_registry"
26
29
  require_relative "mutineer/worker_pool"
27
30
  require_relative "mutineer/runner"
@@ -29,5 +32,6 @@ require_relative "mutineer/reporter"
29
32
  require_relative "mutineer/baseline"
30
33
  require_relative "mutineer/cli"
31
34
 
35
+ # Mutineer is the top-level namespace for the mutation-testing library.
32
36
  module Mutineer
33
37
  end
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
40
54
  description: Mutineer mutates your source one change at a time and runs your Minitest
41
55
  suite against each mutant to find tests that don't actually test anything. Prism-based,
42
56
  fork-isolated, zero runtime dependencies.
@@ -66,11 +80,14 @@ files:
66
80
  - lib/mutineer/mutators/base.rb
67
81
  - lib/mutineer/mutators/boolean_connector.rb
68
82
  - lib/mutineer/mutators/boolean_literal.rb
83
+ - lib/mutineer/mutators/collection_method.rb
69
84
  - lib/mutineer/mutators/comparison.rb
70
85
  - lib/mutineer/mutators/condition_negation.rb
71
86
  - lib/mutineer/mutators/literal_mutation.rb
87
+ - lib/mutineer/mutators/regex_literal.rb
72
88
  - lib/mutineer/mutators/return_nil.rb
73
89
  - lib/mutineer/mutators/statement_removal.rb
90
+ - lib/mutineer/mutators/string_literal.rb
74
91
  - lib/mutineer/pairing.rb
75
92
  - lib/mutineer/parser.rb
76
93
  - lib/mutineer/project.rb