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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "set"
4
5
  require_relative "parser"
5
6
  require_relative "subject"
6
7
 
@@ -8,12 +9,17 @@ module Mutineer
8
9
  # Subject discovery: parse each path and walk its AST for method definitions,
9
10
  # tracking the enclosing class/module namespace.
10
11
  class Project
11
- # Returns Array<Subject>. `only` filters by qualified name (string equality).
12
+ # Discovers subjects from source paths.
13
+ #
14
+ # @param paths [Array<String>] source file paths.
15
+ # @param only [String, nil] optional qualified-name filter.
16
+ # @return [Array<Mutineer::Subject>] discovered subjects.
12
17
  def self.discover(paths, only: nil)
13
18
  subjects = Array(paths).flat_map do |path|
14
19
  result = Parser.parse_file(path)
15
20
  visitor = SubjectVisitor.new(path)
16
21
  visitor.visit(result.value)
22
+ visitor.promote_module_functions!
17
23
  visitor.subjects
18
24
  end
19
25
  only ? subjects.select { |s| s.qualified_name == only } : subjects
@@ -24,31 +30,87 @@ module Mutineer
24
30
  class SubjectVisitor < Prism::Visitor
25
31
  attr_reader :subjects
26
32
 
33
+ # Builds a subject visitor.
34
+ #
35
+ # @param file [String] source file path being visited.
27
36
  def initialize(file)
28
37
  @file = file
29
38
  @namespace_stack = []
30
39
  @subjects = []
31
40
  @singleton_depth = 0
41
+ @module_function_active = false # bareword `module_function` seen in this module body
42
+ @module_function_names = [] # names from `module_function :a, :b` / `module_function def`
32
43
  super()
33
44
  end
34
45
 
46
+ # Promote `module_function :name` / `module_function def name` subjects to
47
+ # singleton after the full walk — the naming call may appear before or after
48
+ # the def, so it can't be decided at visit_def_node time (#20).
49
+ #
50
+ # @return [void]
51
+ def promote_module_functions!
52
+ return if @module_function_names.empty?
53
+
54
+ names = @module_function_names.to_set
55
+ @subjects.each { |s| s.singleton = true if names.include?(s.name) }
56
+ end
57
+
58
+ # Visits class nodes and tracks namespace nesting.
59
+ #
60
+ # @param node [Prism::ClassNode] class node.
61
+ # @return [void]
35
62
  def visit_class_node(node)
36
63
  @namespace_stack.push(extract_constant_name(node.constant_path))
64
+ saved = @module_function_active
65
+ @module_function_active = false # module_function state does not cross a class boundary
37
66
  super
67
+ @module_function_active = saved
38
68
  @namespace_stack.pop
39
69
  end
40
70
 
71
+ # Visits module nodes and tracks namespace nesting.
72
+ #
73
+ # @param node [Prism::ModuleNode] module node.
74
+ # @return [void]
41
75
  def visit_module_node(node)
42
76
  @namespace_stack.push(extract_constant_name(node.constant_path))
77
+ saved = @module_function_active
78
+ @module_function_active = false # each module body starts without module_function active
43
79
  super
80
+ @module_function_active = saved
44
81
  @namespace_stack.pop
45
82
  end
46
83
 
84
+ # Track `module_function` so its methods are recorded as singletons (#20) —
85
+ # the called form is the singleton method on the module object. Bareword
86
+ # `module_function` flips all SUBSEQUENT defs in this body; the argument
87
+ # forms (`:sym`, `def`) name methods promoted after the walk.
88
+ #
89
+ # @param node [Prism::CallNode] call node.
90
+ # @return [void]
91
+ def visit_call_node(node)
92
+ if node.name == :module_function && node.receiver.nil?
93
+ args = node.arguments&.arguments || []
94
+ if args.empty?
95
+ @module_function_active = true
96
+ else
97
+ args.each do |arg|
98
+ @module_function_names << arg.value.to_sym if arg.is_a?(Prism::SymbolNode)
99
+ @module_function_names << arg.name if arg.is_a?(Prism::DefNode)
100
+ end
101
+ end
102
+ end
103
+ super
104
+ end
105
+
47
106
  # Methods inside `class << self` are class methods of the enclosing
48
107
  # namespace, but their def nodes have no receiver — track the singleton
49
108
  # context so they're recorded as singleton (so redefine targets the
50
109
  # singleton_class, not instances). `class << some_other_obj` can't be
51
110
  # represented against the namespace, so its defs are skipped (not recursed).
111
+ #
112
+ # @param node [Prism::SingletonClassNode] singleton-class node.
113
+ # @return [void]
52
114
  def visit_singleton_class_node(node)
53
115
  return unless node.expression.is_a?(Prism::SelfNode)
54
116
 
@@ -57,12 +119,16 @@ module Mutineer
57
119
  @singleton_depth -= 1
58
120
  end
59
121
 
122
+ # Records a discovered method definition.
123
+ #
124
+ # @param node [Prism::DefNode] method definition node.
125
+ # @return [void]
60
126
  def visit_def_node(node)
61
127
  @subjects << Subject.new(
62
128
  file: @file,
63
129
  namespace: @namespace_stack.dup,
64
130
  name: node.name,
65
- singleton: !node.receiver.nil? || @singleton_depth.positive?,
131
+ singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
66
132
  def_node: node
67
133
  )
68
134
  super
@@ -70,6 +136,11 @@ module Mutineer
70
136
 
71
137
  private
72
138
 
139
+ # Extracts a constant name from a Prism constant node.
140
+ #
141
+ # @api private
142
+ # @param node [Prism::Node] constant node.
143
+ # @return [String, nil] constant name.
73
144
  def extract_constant_name(node)
74
145
  case node
75
146
  when Prism::ConstantReadNode
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "stringio"
5
+ require "cgi"
5
6
  require_relative "mutation"
6
7
 
7
8
  module Mutineer
@@ -25,6 +26,8 @@ module Mutineer
25
26
  rendered =
26
27
  if format == "json"
27
28
  json_report(baseline)
29
+ elsif format == "html"
30
+ html_report
28
31
  else
29
32
  sio = StringIO.new
30
33
  human_report(sio, err, threshold)
@@ -41,6 +44,12 @@ module Mutineer
41
44
  end
42
45
  end
43
46
 
47
+ # Renders the human report.
48
+ #
49
+ # @param out [IO] output stream.
50
+ # @param err [IO] error stream.
51
+ # @param threshold [Float] score threshold.
52
+ # @return [void]
44
53
  def human_report(out, err, threshold)
45
54
  if @agg.total.zero?
46
55
  err.puts "No mutations generated — verify target files contain in-scope " \
@@ -75,6 +84,11 @@ module Mutineer
75
84
  # Canonical machine-readable schema (KTD7). survivors/no_coverage are sorted
76
85
  # by (file, line, operator) so output is byte-stable regardless of --jobs
77
86
  # worker finish order (R22).
87
+ # Renders the JSON report.
88
+ #
89
+ # @api private
90
+ # @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
91
+ # @return [String] JSON text.
78
92
  def json_report(baseline = nil)
79
93
  killed = @agg.killed_count
80
94
  survived = @agg.survived_count
@@ -117,6 +131,117 @@ module Mutineer
117
131
  "#{JSON.generate(doc)}\n"
118
132
  end
119
133
 
134
+ # #23: one self-contained HTML file (inline CSS, no external assets) — the
135
+ # overall score + summary counts, a per-source table, and every surviving
136
+ # mutant with its stable id and diff. All source/diff/identifier text is
137
+ # HTML-escaped (CGI.escapeHTML) so a `<`/`>` in source can never break the
138
+ # markup. Reuses survivor_json/per_source_json so one run yields one set of
139
+ # facts regardless of --format.
140
+ def html_report
141
+ score = @agg.mutation_score
142
+ survivors = @agg.surviving_mutants.map { |r| survivor_json(r) }
143
+ .sort_by { |h| [h[:file], h[:line], h[:operator]] }
144
+ per_source = @agg.by_source.map { |file, agg| per_source_json(file, agg) }
145
+ .sort_by { |h| h[:file] }
146
+
147
+ <<~HTML
148
+ <!DOCTYPE html>
149
+ <html lang="en">
150
+ <head>
151
+ <meta charset="utf-8">
152
+ <meta name="viewport" content="width=device-width, initial-scale=1">
153
+ <title>Mutineer Mutation Report</title>
154
+ <style>
155
+ body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
156
+ margin: 2rem; color: #1b1b1b; background: #fafafa; }
157
+ h1 { margin: 0 0 .25rem; }
158
+ .score { font-size: 2.5rem; font-weight: 700; }
159
+ .counts { color: #444; margin: .5rem 0 1.5rem; }
160
+ .counts span { display: inline-block; margin-right: 1rem; white-space: nowrap; }
161
+ table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; background: #fff; }
162
+ th, td { border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }
163
+ th { background: #f0f0f0; }
164
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
165
+ .survivor { background: #fff; border: 1px solid #ddd; border-radius: 4px;
166
+ padding: .75rem 1rem; margin-bottom: 1rem; }
167
+ .survivor h3 { margin: 0 0 .25rem; font-size: 1rem; }
168
+ .meta { color: #555; font-size: .85rem; margin-bottom: .5rem; }
169
+ .id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
170
+ pre.diff { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
171
+ background: #f6f8fa; padding: .5rem .75rem; overflow-x: auto;
172
+ margin: 0; border-radius: 4px; }
173
+ .diff .add { color: #116329; }
174
+ .diff .del { color: #82071e; }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <h1>Mutineer — Mutation Report</h1>
179
+ <div class="score">Score: #{score.nil? ? 'N/A' : "#{score}%"}</div>
180
+ #{summary_html}
181
+ #{per_source_html(per_source)}
182
+ #{survivors_html(survivors)}
183
+ </body>
184
+ </html>
185
+ HTML
186
+ end
187
+
188
+ # The summary counts block for the HTML header.
189
+ def summary_html
190
+ counts = {
191
+ "total" => @agg.total, "killed" => @agg.killed_count,
192
+ "survived" => @agg.survived_count, "no_coverage" => @agg.no_coverage_count,
193
+ "uncapturable" => @agg.uncapturable_count, "ignored" => @agg.ignored_count,
194
+ "skipped" => @agg.skipped_invalid_count,
195
+ "errored" => @agg.errored_count + @agg.timeout_count
196
+ }
197
+ spans = counts.map { |k, v| "<span><strong>#{v}</strong> #{esc(k)}</span>" }.join("\n ")
198
+ "<div class=\"counts\">\n #{spans}\n</div>"
199
+ end
200
+
201
+ # The per-source breakdown table.
202
+ def per_source_html(per_source)
203
+ return "" if per_source.empty?
204
+
205
+ rows = per_source.map do |h|
206
+ score = h[:score].nil? ? "N/A" : "#{h[:score]}%"
207
+ "<tr><td>#{esc(h[:file])}</td><td class=\"num\">#{score}</td>" \
208
+ "<td class=\"num\">#{h[:killed]}</td><td class=\"num\">#{h[:survived]}</td>" \
209
+ "<td class=\"num\">#{h[:no_coverage]}</td></tr>"
210
+ end.join("\n ")
211
+ <<~HTML.chomp
212
+ <h2>Per-source</h2>
213
+ <table>
214
+ <tr><th>File</th><th>Score</th><th>Killed</th><th>Survived</th><th>No coverage</th></tr>
215
+ #{rows}
216
+ </table>
217
+ HTML
218
+ end
219
+
220
+ # The surviving-mutants list, each with subject, location, operator, stable
221
+ # id, and a colorized diff. All text is HTML-escaped.
222
+ def survivors_html(survivors)
223
+ return "<h2>Surviving Mutants</h2>\n<p>None — every covered mutant was killed.</p>" if survivors.empty?
224
+
225
+ cards = survivors.map do |s|
226
+ diff_lines = s[:diff].each_line.map do |line|
227
+ cls = line.start_with?("+") ? "add" : (line.start_with?("-") ? "del" : nil)
228
+ text = esc(line.chomp)
229
+ cls ? "<span class=\"#{cls}\">#{text}</span>" : text
230
+ end.join("\n")
231
+ <<~CARD.chomp
232
+ <div class="survivor">
233
+ <h3>#{esc(s[:subject])}</h3>
234
+ <div class="meta">#{esc(s[:file])}:#{s[:line]} &middot; #{esc(s[:operator])} &middot; <span class="id">#{esc(s[:id])}</span></div>
235
+ <pre class="diff">#{diff_lines}</pre>
236
+ </div>
237
+ CARD
238
+ end.join("\n")
239
+ "<h2>Surviving Mutants</h2>\n#{cards}"
240
+ end
241
+
242
+ # HTML-escapes any text destined for the document (stdlib CGI).
243
+ def esc(text) = CGI.escapeHTML(text.to_s)
244
+
120
245
  # #13: the same delta facts the human report prints, for dashboards. new_survivors
121
246
  # reuse the ignored_json shape (subject/file/line/operator/token/id) and sort
122
247
  # byte-stably so output doesn't depend on --jobs finish order.
@@ -135,6 +260,12 @@ module Mutineer
135
260
  }
136
261
  end
137
262
 
263
+ # Builds per-source JSON.
264
+ #
265
+ # @api private
266
+ # @param file [String] source file path.
267
+ # @param agg [Mutineer::AggregateResult] source aggregate.
268
+ # @return [Hash] per-source JSON object.
138
269
  def per_source_json(file, agg)
139
270
  {
140
271
  file: file, total: agg.total,
@@ -143,6 +274,11 @@ module Mutineer
143
274
  }
144
275
  end
145
276
 
277
+ # Builds survivor JSON.
278
+ #
279
+ # @api private
280
+ # @param result [Mutineer::Result] survivor result.
281
+ # @return [Hash] survivor JSON object.
146
282
  def survivor_json(result)
147
283
  m = result.mutation
148
284
  file = result.subject.file
@@ -198,6 +334,11 @@ module Mutineer
198
334
  [start_line, original_block, mutated_block, token]
199
335
  end
200
336
 
337
+ # Builds no-coverage JSON.
338
+ #
339
+ # @api private
340
+ # @param result [Mutineer::Result] result object.
341
+ # @return [Hash] no-coverage JSON object.
201
342
  def no_coverage_json(result)
202
343
  m = result.mutation
203
344
  file = result.subject.file
@@ -209,6 +350,10 @@ module Mutineer
209
350
  }
210
351
  end
211
352
 
353
+ # Writes the summary block.
354
+ #
355
+ # @param out [IO] output stream.
356
+ # @return [void]
212
357
  def summary(out)
213
358
  out.puts "Summary"
214
359
  out.puts "-------"
@@ -222,6 +367,11 @@ module Mutineer
222
367
  out.puts format("Ignored: %-6d (equivalent, suppressed)", @agg.ignored_count)
223
368
  end
224
369
 
370
+ # Writes the score line.
371
+ #
372
+ # @param out [IO] output stream.
373
+ # @param err [IO] error stream.
374
+ # @return [void]
225
375
  def score_line(out, err)
226
376
  score = @agg.mutation_score
227
377
  excluded = "#{@agg.no_coverage_count} no-coverage, #{@agg.uncapturable_count} uncapturable, " \
@@ -239,6 +389,10 @@ module Mutineer
239
389
  # #11: one line per source after the global summary, so a multi-source run
240
390
  # shows which file is weak. Omitted for a single-source run — the global
241
391
  # summary already says everything (ponytail: no redundant one-line block).
392
+ # Writes the per-source breakdown.
393
+ #
394
+ # @param out [IO] output stream.
395
+ # @return [void]
242
396
  def per_source(out)
243
397
  sources = @agg.by_source
244
398
  return if sources.size <= 1
@@ -274,6 +428,10 @@ module Mutineer
274
428
  out.puts(delta.regressed ? "REGRESSION vs baseline" : "OK: no regression vs baseline")
275
429
  end
276
430
 
431
+ # Writes the survivors block.
432
+ #
433
+ # @param out [IO] output stream.
434
+ # @return [void]
277
435
  def survivors(out)
278
436
  mutants = @agg.surviving_mutants
279
437
  return if mutants.empty?
@@ -288,6 +446,12 @@ module Mutineer
288
446
  end
289
447
  end
290
448
 
449
+ # Writes one survivor entry.
450
+ #
451
+ # @param out [IO] output stream.
452
+ # @param file [String] source file path.
453
+ # @param result [Mutineer::Result] survivor result.
454
+ # @return [void]
291
455
  def survivor(out, file, result)
292
456
  m = result.mutation
293
457
  source = @source_map[file] || File.read(file)
@@ -299,6 +463,11 @@ module Mutineer
299
463
  mutated_block.each_line { |l| out.puts " + #{l.chomp}" }
300
464
  end
301
465
 
466
+ # Writes the final verdict line.
467
+ #
468
+ # @param out [IO] output stream.
469
+ # @param threshold [Float] score threshold.
470
+ # @return [void]
302
471
  def verdict(out, threshold)
303
472
  score = @agg.mutation_score
304
473
  return if score.nil?
@@ -8,90 +8,154 @@ module Mutineer
8
8
  # timeout — the parent SIGKILLed a child that overran its wall clock.
9
9
  # skipped — the mutated source failed to re-parse (invalid); no fork.
10
10
  # no_coverage — no test exercises the mutated line; not run, not scored.
11
- # uncapturable — the line's would-be covering test errored during capture (#9),
12
- # so coverage was lost. Excluded from the denominator exactly
13
- # like no_coverage, but reported separately: it signals a broken
14
- # harness (a test that failed to run), not a genuine coverage gap.
15
- # ignored — a known-equivalent mutant the user suppressed (#10), via an
16
- # inline `# mutineer:disable-line` comment or a `.mutineer.yml`
17
- # `ignore:` id. A pre-fork classification (never run); excluded
18
- # from the denominator so a strong file can reach 100%.
11
+ # uncapturable — the line's would-be covering test errored during capture
12
+ # (#9), so coverage was lost. Excluded from the
13
+ # denominator exactly like no_coverage, but reported
14
+ # separately: it signals a broken harness (a test that
15
+ # failed to run), not a genuine coverage gap.
16
+ # ignored — a known-equivalent mutant the user suppressed (#10), via
17
+ # an inline `# mutineer:disable-line` comment or a
18
+ # `.mutineer.yml` `ignore:` id. A pre-fork classification
19
+ # (never run); excluded from the denominator so a strong
20
+ # file can reach 100%.
19
21
  #
20
22
  # `error` and `skipped` are deliberately distinct: skipped is a pre-fork
21
23
  # validity failure (counted separately by the reporter), error is a runtime
22
24
  # crash. Never conflate them via `details` string parsing. `no_coverage` and
23
- # `uncapturable` are pre-fork selection results (M3/#9): both excluded from the
24
- # score denominator.
25
+ # `uncapturable` are pre-fork selection results (M3/#9): both excluded from
26
+ # the score denominator.
25
27
  #
26
- # `subject`, `mutation`, and `id` are nil when the Result is built by Isolation/
27
- # Runner (which only know the outcome); the orchestrator attaches them afterwards
28
- # via `result.with(subject:, mutation:, id:)` so the Reporter can render survivor
29
- # diffs and emit the stable id. `id` is the content-based MutantId (#10).
28
+ # `subject`, `mutation`, and `id` are nil when the Result is built by
29
+ # Isolation/Runner (which only know the outcome); the orchestrator attaches
30
+ # them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
31
+ # can render survivor diffs and emit the stable id. `id` is the content-based
32
+ # MutantId (#10).
30
33
  Result = Data.define(:status, :details, :subject, :mutation, :id) do
31
- def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
32
- def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
34
+ # Builds a killed result.
35
+ #
36
+ # @return [Mutineer::Result] killed result.
37
+ def self.killed = new(status: :killed, details: nil, subject: nil, mutation: nil, id: nil)
38
+
39
+ # Builds a survived result.
40
+ #
41
+ # @return [Mutineer::Result] survived result.
42
+ def self.survived = new(status: :survived, details: nil, subject: nil, mutation: nil, id: nil)
43
+
44
+ # Builds an error result.
45
+ #
46
+ # @param details [String, nil] error details.
47
+ # @return [Mutineer::Result] error result.
33
48
  def self.error(details = nil) = new(status: :error, details: details, subject: nil, mutation: nil, id: nil)
34
- def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
49
+
50
+ # Builds a timeout result.
51
+ #
52
+ # @return [Mutineer::Result] timeout result.
53
+ def self.timeout = new(status: :timeout, details: nil, subject: nil, mutation: nil, id: nil)
54
+
55
+ # Builds a skipped result.
56
+ #
57
+ # @param details [String, nil] skip details.
58
+ # @return [Mutineer::Result] skipped result.
35
59
  def self.skipped(details = nil) = new(status: :skipped, details: details, subject: nil, mutation: nil, id: nil)
36
- def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
37
- def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
38
- def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
39
60
 
61
+ # Builds a no_coverage result.
62
+ #
63
+ # @return [Mutineer::Result] no-coverage result.
64
+ def self.no_coverage = new(status: :no_coverage, details: nil, subject: nil, mutation: nil, id: nil)
65
+
66
+ # Builds an uncapturable result.
67
+ #
68
+ # @return [Mutineer::Result] uncapturable result.
69
+ def self.uncapturable = new(status: :uncapturable, details: nil, subject: nil, mutation: nil, id: nil)
70
+
71
+ # Builds an ignored result.
72
+ #
73
+ # @return [Mutineer::Result] ignored result.
74
+ def self.ignored = new(status: :ignored, details: nil, subject: nil, mutation: nil, id: nil)
75
+
76
+ # @return [Boolean] true when the status is killed.
40
77
  def killed? = status == :killed
78
+ # @return [Boolean] true when the status is survived.
41
79
  def survived? = status == :survived
80
+ # @return [Boolean] true when the status is error.
42
81
  def error? = status == :error
82
+ # @return [Boolean] true when the status is timeout.
43
83
  def timeout? = status == :timeout
84
+ # @return [Boolean] true when the status is skipped.
44
85
  def skipped? = status == :skipped
86
+ # @return [Boolean] true when the status is no_coverage.
45
87
  def no_coverage? = status == :no_coverage
88
+ # @return [Boolean] true when the status is uncapturable.
46
89
  def uncapturable? = status == :uncapturable
90
+ # @return [Boolean] true when the status is ignored.
47
91
  def ignored? = status == :ignored
92
+
48
93
  end
49
94
 
50
95
  # Aggregates a flat list of Results into counts, the mutation score, and the
51
96
  # surviving-mutant list. The score denominator is killed + survived ONLY
52
- # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout, and
53
- # ignored (#10 equivalent-mutant suppression) are each excluded and surfaced
54
- # separately — so suppressing every survivor reaches 100%. An empty denominator yields a nil score
55
- # (rendered "N/A"), never 0.0 — distinguishing "no testable mutants" from
56
- # "0% killed".
97
+ # (KTD-4): no-coverage, uncapturable, skipped (invalid), errored, timeout,
98
+ # and ignored (#10 equivalent-mutant suppression) are each excluded and
99
+ # surfaced separately — so suppressing every survivor reaches 100%. An empty
100
+ # denominator yields a nil score (rendered "N/A"), never 0.0 —
101
+ # distinguishing "no testable mutants" from "0% killed".
57
102
  class AggregateResult
58
103
  attr_reader :results
59
104
 
105
+ # Builds an aggregate from results.
106
+ #
107
+ # @param results [Array<Mutineer::Result>] classified results.
60
108
  def initialize(results)
61
109
  @results = results
62
110
  @by_status = results.group_by(&:status)
63
111
  end
64
112
 
113
+ # @return [Integer] killed count.
65
114
  def killed_count = count(:killed)
115
+ # @return [Integer] survived count.
66
116
  def survived_count = count(:survived)
117
+ # @return [Integer] no-coverage count.
67
118
  def no_coverage_count = count(:no_coverage)
119
+ # @return [Integer] uncapturable count.
68
120
  def uncapturable_count = count(:uncapturable)
121
+ # @return [Integer] skipped-invalid count.
69
122
  def skipped_invalid_count = count(:skipped)
123
+ # @return [Integer] errored count.
70
124
  def errored_count = count(:error)
125
+ # @return [Integer] timeout count.
71
126
  def timeout_count = count(:timeout)
127
+ # @return [Integer] ignored count.
72
128
  def ignored_count = count(:ignored)
73
129
 
74
130
  # Every generated, classified mutation. NOT the score denominator.
131
+ #
132
+ # @return [Integer] total result count.
75
133
  def total = @results.size
76
134
 
77
135
  # The score denominator (also shown to the reader).
136
+ #
137
+ # @return [Integer] killed plus survived.
78
138
  def covered_count = killed_count + survived_count
79
139
 
80
- # killed / (killed + survived) as a rounded percentage, or nil when nothing
81
- # was testable.
140
+ # Computes the mutation score.
141
+ #
142
+ # @return [Float, nil] score percentage or nil when nothing was testable.
82
143
  def mutation_score
83
144
  return nil if covered_count.zero?
84
145
 
85
146
  (killed_count.to_f / covered_count * 100).round(1)
86
147
  end
87
148
 
149
+ # Returns the surviving mutants.
150
+ #
151
+ # @return [Array<Mutineer::Result>] surviving results.
88
152
  def surviving_mutants = @results.select(&:survived?)
89
153
 
90
- # #11: split into { source_file => AggregateResult } so the Reporter (per-source
91
- # breakdown) and #13 (per-source roll-up / baseline diff) share one shape with
92
- # all the score/count methods. After Runner.execute every result carries a
93
- # subject, so the grouping is total; bare results (no subject, only in unit
94
- # tests) are skipped so file keys stay sortable strings.
154
+ # #11: split into { source_file => AggregateResult } so the Reporter
155
+ # (per-source breakdown) and #13 (per-source roll-up / baseline diff) can
156
+ # reuse the same aggregate math.
157
+ #
158
+ # @return [Hash<String, Mutineer::AggregateResult>] source-file groups.
95
159
  def by_source
96
160
  @results.select { |r| r.subject }
97
161
  .group_by { |r| r.subject.file }
@@ -100,6 +164,10 @@ module Mutineer
100
164
 
101
165
  private
102
166
 
167
+ # Counts results for a status.
168
+ #
169
+ # @param status [Symbol] result status.
170
+ # @return [Integer] count for that status.
103
171
  def count(status) = (@by_status[status] || []).size
104
172
  end
105
173
  end
@@ -32,6 +32,9 @@ module Mutineer
32
32
  # The parent process `require`s each source file so its classes exist; forked
33
33
  # children inherit them, so a covering test file's own require_relative of the
34
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.
35
38
  def self.execute(config)
36
39
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
37
40
 
@@ -124,13 +127,17 @@ module Mutineer
124
127
  results =
125
128
  begin
126
129
  framework = config.framework
127
- bare = WorkerPool.new(config.jobs).run(jobs) do |subject, mutation|
130
+ # #21: --fail-fast stops scheduling new mutants after the first survivor;
131
+ # in-flight workers drain, unscheduled jobs stay nil (dropped below).
132
+ stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
133
+ bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
128
134
  run(mutation, source_file: subject.file, coverage_map: coverage_map,
129
135
  subject: subject, strategy: strategy, rails: config.rails, framework: framework)
130
136
  end
131
137
  # The bare Results carry only status (Subjects hold live AST nodes that
132
138
  # do not marshal); reattach subject+mutation+id in the parent, in order.
133
- bare.each_with_index.map { |r, i| r.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
139
+ # filter_map drops nils for jobs --fail-fast left unscheduled.
140
+ bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
134
141
  ensure
135
142
  sweep_orphans(source_dirs)
136
143
  end
@@ -215,6 +222,11 @@ module Mutineer
215
222
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
216
223
  end
217
224
 
225
+ # Removes stale mutant tempfiles from the given directories.
226
+ #
227
+ # @api private
228
+ # @param dirs [Array<String>] directories to sweep.
229
+ # @return [void]
218
230
  def self.sweep_orphans(dirs)
219
231
  dirs.each do |dir|
220
232
  Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
@@ -223,6 +235,17 @@ module Mutineer
223
235
  end
224
236
  end
225
237
 
238
+ # Runs a single mutation through isolation.
239
+ #
240
+ # @param mutation [Mutineer::Mutation] mutation to run.
241
+ # @param source_file [String] source file path.
242
+ # @param coverage_map [Mutineer::CoverageMap, nil] coverage map.
243
+ # @param subject [Mutineer::Subject, nil] subject for surgical strategy.
244
+ # @param strategy [String] mutation strategy.
245
+ # @param timeout [Integer] child timeout in seconds.
246
+ # @param rails [Boolean] whether Rails reconnect handling is enabled.
247
+ # @param framework [String] test framework name.
248
+ # @return [Mutineer::Result] mutant result.
226
249
  def self.run(mutation, source_file:, coverage_map: nil, subject: nil, strategy: "reload",
227
250
  timeout: Isolation::DEFAULT_TIMEOUT, rails: false, framework: "minitest")
228
251
  source = File.read(source_file)
@@ -257,6 +280,10 @@ module Mutineer
257
280
  end
258
281
  end
259
282
 
283
+ # Reconnects ActiveRecord in a forked child when available.
284
+ #
285
+ # @api private
286
+ # @return [void]
260
287
  def self.reconnect_active_record
261
288
  return unless defined?(ActiveRecord::Base)
262
289