mutineer 0.6.2 → 0.8.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.
@@ -10,6 +10,8 @@ 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 "mutant_id"
14
+ require "set"
13
15
 
14
16
  module Mutineer
15
17
  # Orchestrates one mutation end-to-end: apply it textually, validate the
@@ -30,6 +32,9 @@ module Mutineer
30
32
  # The parent process `require`s each source file so its classes exist; forked
31
33
  # children inherit them, so a covering test file's own require_relative of the
32
34
  # source is a no-op and does not clobber the mutated `load` (spec §7).
35
+ #
36
+ # @param config [Mutineer::Config] run configuration.
37
+ # @return [Array(Mutineer::AggregateResult, Hash<String, String>)] aggregate and source map.
33
38
  def self.execute(config)
34
39
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
35
40
 
@@ -70,7 +75,8 @@ module Mutineer
70
75
  source_paths: config.sources, test_paths: config.tests,
71
76
  cache_dir: config.cache_dir, project_root: config.project_root,
72
77
  load_paths: config.load_paths, framework: config.framework,
73
- boot_path: File.expand_path(config.boot, config.project_root)
78
+ boot_path: File.expand_path(config.boot, config.project_root),
79
+ verbose: config.verbose
74
80
  ).build_via_fork(rails: config.rails)
75
81
  else
76
82
  coverage_map = CoverageMap.new(
@@ -81,13 +87,28 @@ module Mutineer
81
87
  end
82
88
 
83
89
  # Collect every (subject, mutation) up front so the pool can fan them out.
90
+ # #10: a mutant the user marked known-equivalent (inline disable-line comment
91
+ # or .mutineer.yml ignore id) is classified :ignored here and NEVER forked —
92
+ # it is removed from the killed+survived denominator so a strong file reaches
93
+ # 100%. The stable id is computed per subject (occurrence needs the full list)
94
+ # and carried on every job so the parent can reattach it after the run.
84
95
  source_map = {}
96
+ disabled_map = {}
97
+ ignore_set = config.ignore.to_set
85
98
  jobs = []
99
+ ignored_results = []
86
100
  Project.discover(config.sources, only: config.only).each do |subject|
87
101
  source = (source_map[subject.file] ||= File.read(subject.file))
88
- operator_classes.each do |klass|
89
- klass.new.mutations_for(subject, source).each do |mutation|
90
- jobs << [subject, mutation]
102
+ disabled = (disabled_map[subject.file] ||= suppress_map(source))
103
+ mutations = operator_classes.flat_map { |klass| klass.new.mutations_for(subject, source) }
104
+ ids = MutantId.for_subject(subject, source, mutations)
105
+ mutations.each_with_index do |mutation, i|
106
+ id = ids[i]
107
+ line = source.byteslice(0, mutation.start_offset).count("\n") + 1
108
+ if suppressed?(mutation.operator, line, id, disabled, ignore_set)
109
+ ignored_results << Result.ignored.with(subject: subject, mutation: mutation, id: id)
110
+ else
111
+ jobs << [subject, mutation, id]
91
112
  end
92
113
  end
93
114
  end
@@ -111,13 +132,42 @@ module Mutineer
111
132
  subject: subject, strategy: strategy, rails: config.rails, framework: framework)
112
133
  end
113
134
  # The bare Results carry only status (Subjects hold live AST nodes that
114
- # do not marshal); reattach subject+mutation in the parent, in order.
115
- bare.each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1]) }
135
+ # do not marshal); reattach subject+mutation+id in the parent, in order.
136
+ bare.each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
116
137
  ensure
117
138
  sweep_orphans(source_dirs)
118
139
  end
119
140
 
120
- [AggregateResult.new(results), source_map]
141
+ [AggregateResult.new(results + ignored_results), source_map]
142
+ end
143
+
144
+ # Scan a source once into { line_number => :all | Set[operator_syms] } from
145
+ # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
146
+ # sits on the same physical line as the code it silences). A bare marker
147
+ # disables every operator on that line; `disable-line a, b` only the listed
148
+ # operators. Block-form disable/enable ranges are intentionally not supported.
149
+ def self.suppress_map(source)
150
+ map = {}
151
+ source.each_line.with_index(1) do |text, line|
152
+ next unless (m = text.match(/#\s*mutineer:disable-line(?:\s+([\w,\s]+))?/))
153
+
154
+ ops = m[1]
155
+ map[line] = ops ? ops.split(",").map { |o| o.strip.to_sym }.reject(&:empty?).to_set : :all
156
+ end
157
+ map
158
+ end
159
+
160
+ # True when this mutant is suppressed: its line bears a disable-line marker
161
+ # (bare, or scoped to its operator), OR its stable id is in the config ignore
162
+ # list. Checked at job-build time so a suppressed mutant is never forked.
163
+ def self.suppressed?(operator, line, id, disabled, ignore_set)
164
+ return true if ignore_set.include?(id)
165
+
166
+ case (entry = disabled[line])
167
+ when :all then true
168
+ when Set then entry.include?(operator)
169
+ else false
170
+ end
121
171
  end
122
172
 
123
173
  # --since: keep only jobs whose mutation lands on a line changed since the git
@@ -168,6 +218,11 @@ module Mutineer
168
218
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
169
219
  end
170
220
 
221
+ # Removes stale mutant tempfiles from the given directories.
222
+ #
223
+ # @api private
224
+ # @param dirs [Array<String>] directories to sweep.
225
+ # @return [void]
171
226
  def self.sweep_orphans(dirs)
172
227
  dirs.each do |dir|
173
228
  Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
@@ -176,6 +231,17 @@ module Mutineer
176
231
  end
177
232
  end
178
233
 
234
+ # Runs a single mutation through isolation.
235
+ #
236
+ # @param mutation [Mutineer::Mutation] mutation to run.
237
+ # @param source_file [String] source file path.
238
+ # @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
239
+ # @param subject [Mutineer::Subject, nil] subject for surgical strategy.
240
+ # @param strategy [String] mutation strategy.
241
+ # @param timeout [Integer] child timeout in seconds.
242
+ # @param rails [Boolean] whether Rails reconnect handling is enabled.
243
+ # @param framework [String] test framework name.
244
+ # @return [Mutineer::Result] mutant result.
179
245
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
180
246
  timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
181
247
  source = File.read(source_file)
@@ -189,7 +255,11 @@ module Mutineer
189
255
  # covering test files run in the child.
190
256
  line = source.byteslice(0, mutation.start_offset).count("\n") + 1
191
257
  chosen = coverage_map.tests_for(source_file, line)
192
- return Result.no_coverage if chosen.empty?
258
+ # #9: distinguish a genuine coverage gap from a line whose would-be test
259
+ # errored during capture (coverage lost) — the latter is :uncapturable.
260
+ if chosen.empty?
261
+ return coverage_map.uncapturable_source?(source_file) ? Result.uncapturable : Result.no_coverage
262
+ end
193
263
 
194
264
  abs_tests = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
195
265
 
@@ -206,13 +276,35 @@ module Mutineer
206
276
  end
207
277
  end
208
278
 
279
+ # Reconnects ActiveRecord in a forked child when available.
280
+ #
281
+ # @api private
282
+ # @return [void]
209
283
  def self.reconnect_active_record
210
284
  return unless defined?(ActiveRecord::Base)
211
285
 
212
- ActiveRecord::Base.connection_handler.clear_all_connections!
286
+ base = ActiveRecord::Base
287
+ # #8: clearing connections here drops an open transactional-fixture
288
+ # transaction, so the test loses its fixture rows and fails. Skip the clear
289
+ # when a transaction is open; otherwise clear (v0.2 per-fork write-safety).
290
+ return if fixture_transaction_open?(base)
291
+
292
+ base.connection_handler.clear_all_connections!
213
293
  rescue StandardError
214
294
  nil
215
295
  end
216
296
  private_class_method :reconnect_active_record
297
+
298
+ # Pure, injectable predicate: true when a transactional-fixture transaction is
299
+ # already open on the connection. Keys off open_transactions (KTD-2) so it is
300
+ # correct whenever the transaction exists, regardless of when it opened. Any
301
+ # probe error degrades safe to false -> caller clears (existing behaviour).
302
+ def self.fixture_transaction_open?(base)
303
+ pool = base.connection_pool
304
+ pool.active_connection? && base.connection.open_transactions.positive?
305
+ rescue StandardError
306
+ false
307
+ end
308
+ private_class_method :fixture_transaction_open?
217
309
  end
218
310
  end
@@ -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.6.2"
4
+ # Current Mutineer release version.
5
+ VERSION = "0.8.0"
5
6
  end
@@ -3,21 +3,30 @@
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
 
25
+ # Runs the work items through the pool.
26
+ #
27
+ # @param items [Array<Array>] work items.
28
+ # @yieldparam item [Array] one work item.
29
+ # @return [Array<Mutineer::Result>] results in input order.
21
30
  def run(items)
22
31
  results = Array.new(items.size)
23
32
  queue = (0...items.size).to_a
@@ -33,17 +42,19 @@ module Mutineer
33
42
 
34
43
  private
35
44
 
45
+ # R1: the child must ALWAYS hard-exit. If yield raises, marshal an error
46
+ # Result and exit! in `ensure` — otherwise the child unwinds normally and
47
+ # our Minitest at_exit autorun re-runs the parent suite inside the worker,
48
+ # losing the real error.
36
49
  def fill(items, queue, running)
37
50
  while running.size < @size && !queue.empty?
38
51
  idx = queue.shift
39
52
  rd, wr = IO.pipe
53
+ rd.binmode # #19: Marshal output is binary — keep the pipe byte-exact
54
+ wr.binmode
40
55
  begin
41
56
  pid = fork do
42
57
  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
58
  payload =
48
59
  begin
49
60
  yield(*items[idx])
@@ -74,13 +85,13 @@ module Mutineer
74
85
  end
75
86
  end
76
87
 
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).
88
+ # Drain pipes with IO.select and reap a child only on EOF (#4). The old
89
+ # code reaped first and read after — but a child whose payload exceeds the
90
+ # OS pipe buffer (~64KB) blocks on `write` before it can exit, so it was
91
+ # never reaped and the pool deadlocked. Reading concurrently keeps the
92
+ # pipe drained so the child can finish and exit; EOF means it closed its
93
+ # write end (done writing). We waitpid only OUR known pids (R6: never
94
+ # wait2(-1), which would steal the host suite's children).
84
95
  def reap(results, running)
85
96
  return if running.empty?
86
97
 
@@ -104,11 +115,13 @@ module Mutineer
104
115
  end
105
116
  end
106
117
 
118
+ # R6: a partial/garbage Marshal stream (dead worker) must not crash the
119
+ # pool — degrade to an error Result.
120
+ # @param data [String] marshaled payload.
121
+ # @return [Mutineer::Result] decoded result or error result.
107
122
  def decode(data)
108
123
  return Result.error("worker produced no result") if data.empty?
109
124
 
110
- # R6: a partial/garbage Marshal stream (dead worker) must not crash the
111
- # pool — degrade to an error Result.
112
125
  Marshal.load(data)
113
126
  rescue StandardError => e
114
127
  Result.error("worker result unreadable: #{e.class}: #{e.message}")
data/lib/mutineer.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "mutineer/parser"
6
6
  require_relative "mutineer/subject"
7
7
  require_relative "mutineer/mutation"
8
8
  require_relative "mutineer/project"
9
+ require_relative "mutineer/pairing"
9
10
  require_relative "mutineer/result"
10
11
  require_relative "mutineer/coverage_map"
11
12
  require_relative "mutineer/changed_lines"
@@ -25,7 +26,9 @@ require_relative "mutineer/mutator_registry"
25
26
  require_relative "mutineer/worker_pool"
26
27
  require_relative "mutineer/runner"
27
28
  require_relative "mutineer/reporter"
29
+ require_relative "mutineer/baseline"
28
30
  require_relative "mutineer/cli"
29
31
 
32
+ # Mutineer is the top-level namespace for the mutation-testing library.
30
33
  module Mutineer
31
34
  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.6.2
4
+ version: 0.8.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.
@@ -52,12 +66,14 @@ files:
52
66
  - README.md
53
67
  - bin/mutineer
54
68
  - lib/mutineer.rb
69
+ - lib/mutineer/baseline.rb
55
70
  - lib/mutineer/changed_lines.rb
56
71
  - lib/mutineer/cli.rb
57
72
  - lib/mutineer/config.rb
58
73
  - lib/mutineer/coverage_map.rb
59
74
  - lib/mutineer/isolation.rb
60
75
  - lib/mutineer/minitest_integration.rb
76
+ - lib/mutineer/mutant_id.rb
61
77
  - lib/mutineer/mutation.rb
62
78
  - lib/mutineer/mutator_registry.rb
63
79
  - lib/mutineer/mutators/arithmetic.rb
@@ -69,6 +85,7 @@ files:
69
85
  - lib/mutineer/mutators/literal_mutation.rb
70
86
  - lib/mutineer/mutators/return_nil.rb
71
87
  - lib/mutineer/mutators/statement_removal.rb
88
+ - lib/mutineer/pairing.rb
72
89
  - lib/mutineer/parser.rb
73
90
  - lib/mutineer/project.rb
74
91
  - lib/mutineer/reporter.rb